From 35cfa4e2e0ce29346400d296ef44c6205c2056b6 Mon Sep 17 00:00:00 2001 From: Thomas Johansen Date: Thu, 15 Sep 2016 19:34:03 +0200 Subject: [PATCH 001/403] :arrow_up: electron@1.4.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cb76be74507..1ce395efd14 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/atom/atom/issues" }, "license": "MIT", - "electronVersion": "1.3.15", + "electronVersion": "1.4.0", "dependencies": { "async": "0.2.6", "atom-keymap": "8.1.2", From ceb05f03e2786aa5bf439176a67061c6259c9ac1 Mon Sep 17 00:00:00 2001 From: Thomas Johansen Date: Thu, 22 Sep 2016 09:17:46 +0200 Subject: [PATCH 002/403] :green_heart: Make the "invalid" Babel fixture throw Due to the V8 upgrade we need to tune the fixture to make it throw. Using a generator function does the trick :zap: --- spec/fixtures/babel/invalid.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/fixtures/babel/invalid.js b/spec/fixtures/babel/invalid.js index f02fd2fd6f0..585a4365bbf 100644 --- a/spec/fixtures/babel/invalid.js +++ b/spec/fixtures/babel/invalid.js @@ -1,3 +1,3 @@ 'use 6to6'; -module.exports = async function hello() {} +module.exports = async function* hello() {} From af3cb247c4c393fecb190acfbbf8d71bd51a9ef2 Mon Sep 17 00:00:00 2001 From: Thomas Johansen Date: Thu, 22 Sep 2016 13:36:44 +0200 Subject: [PATCH 003/403] :arrow_up: electron@1.4.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1ce395efd14..2c3eadf0037 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/atom/atom/issues" }, "license": "MIT", - "electronVersion": "1.4.0", + "electronVersion": "1.4.1", "dependencies": { "async": "0.2.6", "atom-keymap": "8.1.2", From 4391c4fec49244eacf532e47b7addf8eac70e86e Mon Sep 17 00:00:00 2001 From: Thomas Johansen Date: Thu, 6 Oct 2016 09:23:32 +0200 Subject: [PATCH 004/403] :arrow_up: electron@1.4.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2c3eadf0037..e9962ed268e 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/atom/atom/issues" }, "license": "MIT", - "electronVersion": "1.4.1", + "electronVersion": "1.4.2", "dependencies": { "async": "0.2.6", "atom-keymap": "8.1.2", From 56e84e99a615c667565c12a2a8e143e3a0b8eef9 Mon Sep 17 00:00:00 2001 From: Thomas Johansen Date: Sat, 8 Oct 2016 09:58:36 +0200 Subject: [PATCH 005/403] :arrow_up: electron@1.4.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e9962ed268e..e475090a81c 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/atom/atom/issues" }, "license": "MIT", - "electronVersion": "1.4.2", + "electronVersion": "1.4.3", "dependencies": { "async": "0.2.6", "atom-keymap": "8.1.2", From ac088425a9c37acc5109ec8e7ba3b41653060c81 Mon Sep 17 00:00:00 2001 From: Thomas Johansen Date: Thu, 20 Oct 2016 07:52:54 +0200 Subject: [PATCH 006/403] :arrow_up: electron@1.4.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e475090a81c..394068b7249 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/atom/atom/issues" }, "license": "MIT", - "electronVersion": "1.4.3", + "electronVersion": "1.4.4", "dependencies": { "async": "0.2.6", "atom-keymap": "8.1.2", From fc4178f460a9c00cec0b1d32bb4c9af1abddb3cb Mon Sep 17 00:00:00 2001 From: Thomas Johansen Date: Wed, 2 Nov 2016 09:20:41 +0100 Subject: [PATCH 007/403] :arrow_up: electron@1.4.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 394068b7249..39c2e78405c 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/atom/atom/issues" }, "license": "MIT", - "electronVersion": "1.4.4", + "electronVersion": "1.4.5", "dependencies": { "async": "0.2.6", "atom-keymap": "8.1.2", From 9bc214aa092f02a551db921f267f948f0ce0c029 Mon Sep 17 00:00:00 2001 From: Thomas Johansen Date: Thu, 10 Nov 2016 10:11:24 +0100 Subject: [PATCH 008/403] :arrow_up: electron@1.4.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 39c2e78405c..0bce8c551fe 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/atom/atom/issues" }, "license": "MIT", - "electronVersion": "1.4.5", + "electronVersion": "1.4.6", "dependencies": { "async": "0.2.6", "atom-keymap": "8.1.2", From c005f7564a9b4eb6e8ccfde524d4c4ef8fb38872 Mon Sep 17 00:00:00 2001 From: Wliu Date: Mon, 14 Nov 2016 14:26:56 -0500 Subject: [PATCH 009/403] Remove unneeded backgroundColor --- src/main-process/atom-window.coffee | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main-process/atom-window.coffee b/src/main-process/atom-window.coffee index da5080a9fce..556f6a3a01a 100644 --- a/src/main-process/atom-window.coffee +++ b/src/main-process/atom-window.coffee @@ -26,11 +26,6 @@ class AtomWindow options = show: false title: 'Atom' - # Add an opaque backgroundColor (instead of keeping the default - # transparent one) to prevent subpixel anti-aliasing from being disabled. - # We believe this is a regression introduced with Electron 0.37.3, and - # thus we should remove this as soon as a fix gets released. - backgroundColor: "#fff" webPreferences: # Prevent specs from throttling when the window is in the background: # this should result in faster CI builds, and an improvement in the From d446fb4130c13ac8c09c6123029cd06dcb2cbabe Mon Sep 17 00:00:00 2001 From: Thomas Johansen Date: Thu, 17 Nov 2016 09:29:26 +0100 Subject: [PATCH 010/403] :arrow_up: electron@1.4.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0bce8c551fe..ba0e797a726 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/atom/atom/issues" }, "license": "MIT", - "electronVersion": "1.4.6", + "electronVersion": "1.4.7", "dependencies": { "async": "0.2.6", "atom-keymap": "8.1.2", From 305fb2eac573a353982f9ff530e8876f0385dea5 Mon Sep 17 00:00:00 2001 From: Thomas Johansen Date: Wed, 30 Nov 2016 08:52:30 +0100 Subject: [PATCH 011/403] :arrow_up: electron@1.4.10 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ba0e797a726..5f1c010aef0 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/atom/atom/issues" }, "license": "MIT", - "electronVersion": "1.4.7", + "electronVersion": "1.4.10", "dependencies": { "async": "0.2.6", "atom-keymap": "8.1.2", From d23dff2a3c5ae4a69989aa3d8f603b84a23aba5f Mon Sep 17 00:00:00 2001 From: Thomas Johansen Date: Thu, 8 Dec 2016 07:41:14 +0100 Subject: [PATCH 012/403] :arrow_up: electron@1.4.11 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5f1c010aef0..49c2f7e426c 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/atom/atom/issues" }, "license": "MIT", - "electronVersion": "1.4.10", + "electronVersion": "1.4.11", "dependencies": { "async": "0.2.6", "atom-keymap": "8.1.2", From 38c0f7b036de5d64115c60238e617ae0eef622ed Mon Sep 17 00:00:00 2001 From: Wliu <50Wliu@users.noreply.github.com> Date: Thu, 8 Dec 2016 11:44:19 -0500 Subject: [PATCH 013/403] autoSubmit -> uploadToServer --- src/crash-reporter-start.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/crash-reporter-start.js b/src/crash-reporter-start.js index 98b210d0605..035a94e3d14 100644 --- a/src/crash-reporter-start.js +++ b/src/crash-reporter-start.js @@ -4,7 +4,7 @@ module.exports = function (extra) { productName: 'Atom', companyName: 'GitHub', submitURL: 'https://crashreporter.atom.io', - autoSubmit: false, + uploadToServer: false, extra: extra }) } From dfa3ab1c6e0f1176fecd6a47c674230f0dda9034 Mon Sep 17 00:00:00 2001 From: Wliu <50Wliu@users.noreply.github.com> Date: Thu, 8 Dec 2016 11:48:10 -0500 Subject: [PATCH 014/403] process.versions.atom-shell -> process.versions.electron --- src/package.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/package.coffee b/src/package.coffee index fbe4bce23d8..039ccf9d3eb 100644 --- a/src/package.coffee +++ b/src/package.coffee @@ -751,7 +751,7 @@ class Package "installed-packages:#{@name}:#{@metadata.version}:build-error" getIncompatibleNativeModulesStorageKey: -> - electronVersion = process.versions['electron'] ? process.versions['atom-shell'] + electronVersion = process.versions.electron "installed-packages:#{@name}:#{@metadata.version}:electron-#{electronVersion}:incompatible-native-modules" getCanDeferMainModuleRequireStorageKey: -> From d5c13d15bf22ee8fee7dfcd69dca64df0122790a Mon Sep 17 00:00:00 2001 From: Wliu <50Wliu@users.noreply.github.com> Date: Thu, 8 Dec 2016 11:50:10 -0500 Subject: [PATCH 015/403] setZoomLevelLimits -> setVisualZoomLevelLimits --- src/main-process/atom-window.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main-process/atom-window.coffee b/src/main-process/atom-window.coffee index 556f6a3a01a..518fd63e8f4 100644 --- a/src/main-process/atom-window.coffee +++ b/src/main-process/atom-window.coffee @@ -310,4 +310,4 @@ class AtomWindow copy: -> @browserWindow.copy() disableZoom: -> - @browserWindow.webContents.setZoomLevelLimits(1, 1) + @browserWindow.webContents.setVisualZoomLevelLimits(1, 1) From 575dc7e5b7b0ce88cef59de65d31f43492824fca Mon Sep 17 00:00:00 2001 From: Thomas Johansen Date: Sat, 10 Dec 2016 09:59:33 +0100 Subject: [PATCH 016/403] :arrow_up: electron@1.4.12 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 49c2f7e426c..fc88172c9e6 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/atom/atom/issues" }, "license": "MIT", - "electronVersion": "1.4.11", + "electronVersion": "1.4.12", "dependencies": { "async": "0.2.6", "atom-keymap": "8.1.2", From e63b17a9fe71152d8bc7f05e8cc0a7271610667f Mon Sep 17 00:00:00 2001 From: Thomas Johansen Date: Tue, 20 Dec 2016 10:45:23 +0100 Subject: [PATCH 017/403] :arrow_up: electron-chromedriver --- script/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/package.json b/script/package.json index bb213ba9a80..c4b20a63fc5 100644 --- a/script/package.json +++ b/script/package.json @@ -8,7 +8,7 @@ "colors": "1.1.2", "csslint": "1.0.2", "donna": "1.0.13", - "electron-chromedriver": "~1.3", + "electron-chromedriver": "~1.4", "electron-link": "0.0.23", "electron-mksnapshot": "~1.3", "electron-packager": "7.3.0", From f6e5a46a85b40f2d70b7c3b57cb01be6f2b36d40 Mon Sep 17 00:00:00 2001 From: Thomas Johansen Date: Wed, 21 Dec 2016 11:24:41 +0100 Subject: [PATCH 018/403] :arrow_up: electron@1.4.13 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fc88172c9e6..240ed825ed4 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/atom/atom/issues" }, "license": "MIT", - "electronVersion": "1.4.12", + "electronVersion": "1.4.13", "dependencies": { "async": "0.2.6", "atom-keymap": "8.1.2", From 797abf96bdab0c9075091d6a30b92cc8bc447caf Mon Sep 17 00:00:00 2001 From: Thomas Johansen Date: Mon, 16 Jan 2017 09:22:05 +0100 Subject: [PATCH 019/403] :arrow_up: electron@1.4.14 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 240ed825ed4..ccf3fbe8813 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/atom/atom/issues" }, "license": "MIT", - "electronVersion": "1.4.13", + "electronVersion": "1.4.14", "dependencies": { "async": "0.2.6", "atom-keymap": "8.1.2", From 27db3bafda825af14eac0071f16242b134fc0521 Mon Sep 17 00:00:00 2001 From: Thomas Johansen Date: Wed, 25 Jan 2017 08:50:32 +0100 Subject: [PATCH 020/403] :arrow_up: electron@1.4.15 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ccf3fbe8813..9fff81ad371 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/atom/atom/issues" }, "license": "MIT", - "electronVersion": "1.4.14", + "electronVersion": "1.4.15", "dependencies": { "async": "0.2.6", "atom-keymap": "8.1.2", From 1d8b9b84d17a1b2b6c03b28059b54f28d2da4119 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 8 Feb 2017 14:55:10 -0700 Subject: [PATCH 021/403] Use IntersectionObserver to detect when editor becomes visible This is an alternative to relying on pollDOM to detect when the editor becomes visible. Our goal is to remove pollDOM in the next few commits. Signed-off-by: Max Brunsfeld --- src/text-editor-component.coffee | 29 ++++++++++++++--------------- src/text-editor-element.coffee | 3 ++- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/text-editor-component.coffee b/src/text-editor-component.coffee index 89e8f0783a5..4be1c8cbc7b 100644 --- a/src/text-editor-component.coffee +++ b/src/text-editor-component.coffee @@ -108,7 +108,6 @@ class TextEditorComponent @disposables.add @views.pollDocument(@pollDOM) @updateSync() - @checkForVisibilityChange() @initialized = true destroy: -> @@ -124,6 +123,15 @@ class TextEditorComponent @onVerticalScroll = null @onHorizontalScroll = null + @intersectionObserver.disconnect() + + didAttach: -> + @intersectionObserver = new IntersectionObserver((entries) => + if entries[entries.length - 1].intersectionRatio isnt 0 + @becameVisible() + ) + @intersectionObserver.observe(@domNode) + getDomNode: -> @domNode @@ -647,9 +655,10 @@ class TextEditorComponent @handleStylingChange() handleStylingChange: => - @sampleFontStyling() - @sampleBackgroundColors() - @invalidateMeasurements() + if @isVisible() + @sampleFontStyling() + @sampleBackgroundColors() + @invalidateMeasurements() handleDragUntilMouseUp: (dragHandler) -> dragging = false @@ -735,23 +744,13 @@ class TextEditorComponent @domNode? and (@domNode.offsetHeight > 0 or @domNode.offsetWidth > 0) pollDOM: => - unless @checkForVisibilityChange() + if @isVisible() @sampleBackgroundColors() @measureWindowSize() @measureDimensions() @sampleFontStyling() @overlayManager?.measureOverlays() - checkForVisibilityChange: -> - if @isVisible() - if @wasVisible - false - else - @becameVisible() - @wasVisible = true - else - @wasVisible = false - # Measure explicitly-styled height and width and relay them to the model. If # these values aren't explicitly styled, we assume the editor is unconstrained # and use the scrollHeight / scrollWidth as its height and width in diff --git a/src/text-editor-element.coffee b/src/text-editor-element.coffee index 26e3bae1231..88793dec3dd 100644 --- a/src/text-editor-element.coffee +++ b/src/text-editor-element.coffee @@ -58,8 +58,8 @@ class TextEditorElement extends HTMLElement @buildModel() unless @getModel()? @assert(@model.isAlive(), "Attaching a view for a destroyed editor") @mountComponent() unless @component? + @component.didAttach() @listenForComponentEvents() - @component.checkForVisibilityChange() if @hasFocus() @focused() @emitter.emit("did-attach") @@ -94,6 +94,7 @@ class TextEditorElement extends HTMLElement @model.setUpdatedSynchronously(@isUpdatedSynchronously()) @initializeContent() @mountComponent() + @component.didAttach() if document.contains(this) @addGrammarScopeAttribute() @addMiniAttribute() if @model.isMini() @addEncodingAttribute() From 39ac7d99b9b772a7eb1b488310fa7f1e628a7296 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 8 Feb 2017 14:55:35 -0700 Subject: [PATCH 022/403] Detect text editor resize via scroll event hack This avoids needing to poll to detect resizes. Signed-off-by: Max Brunsfeld --- package.json | 1 + src/text-editor-component.coffee | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 9fff81ad371..073d0da06c2 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "color": "^0.7.3", "dedent": "^0.6.0", "devtron": "1.3.0", + "element-resize-detector": "^1.1.10", "event-kit": "^2.3.0", "find-parent-dir": "^0.3.0", "first-mate": "7.0.4", diff --git a/src/text-editor-component.coffee b/src/text-editor-component.coffee index 4be1c8cbc7b..358cdfc591a 100644 --- a/src/text-editor-component.coffee +++ b/src/text-editor-component.coffee @@ -1,8 +1,9 @@ scrollbarStyle = require 'scrollbar-style' {Range, Point} = require 'text-buffer' -{CompositeDisposable} = require 'event-kit' +{CompositeDisposable, Disposable} = require 'event-kit' {ipcRenderer} = require 'electron' Grim = require 'grim' +elementResizeDetector = require('element-resize-detector')({strategy: 'scroll'}) TextEditorPresenter = require './text-editor-presenter' GutterContainerComponent = require './gutter-container-component' @@ -132,6 +133,10 @@ class TextEditorComponent ) @intersectionObserver.observe(@domNode) + measureDimensions = @measureDimensions.bind(this) + elementResizeDetector.listenTo(@domNode, measureDimensions) + @disposables.add(new Disposable => elementResizeDetector.removeListener(@domNode, measureDimensions)) + getDomNode: -> @domNode @@ -747,7 +752,6 @@ class TextEditorComponent if @isVisible() @sampleBackgroundColors() @measureWindowSize() - @measureDimensions() @sampleFontStyling() @overlayManager?.measureOverlays() From 25db2bad04b6dde6650ac30fc1e195855d1364a5 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 8 Feb 2017 15:20:56 -0700 Subject: [PATCH 023/403] Null guard destruction of intersection observer --- src/text-editor-component.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/text-editor-component.coffee b/src/text-editor-component.coffee index 358cdfc591a..a82f76bd096 100644 --- a/src/text-editor-component.coffee +++ b/src/text-editor-component.coffee @@ -124,7 +124,7 @@ class TextEditorComponent @onVerticalScroll = null @onHorizontalScroll = null - @intersectionObserver.disconnect() + @intersectionObserver?.disconnect() didAttach: -> @intersectionObserver = new IntersectionObserver((entries) => From cc4c437f3da48d6c3a361b068bc5fb7253414da6 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 8 Feb 2017 15:23:00 -0700 Subject: [PATCH 024/403] Ensure 0-height editors are correctly detected as visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is unlikely but it’s easy enough to support Signed-off-by: Max Brunsfeld --- src/text-editor-component.coffee | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/text-editor-component.coffee b/src/text-editor-component.coffee index a82f76bd096..d2ca1134778 100644 --- a/src/text-editor-component.coffee +++ b/src/text-editor-component.coffee @@ -128,7 +128,8 @@ class TextEditorComponent didAttach: -> @intersectionObserver = new IntersectionObserver((entries) => - if entries[entries.length - 1].intersectionRatio isnt 0 + {intersectionRect} = entries[entries.length - 1] + if intersectionRect.width > 0 or intersectionRect.height > 0 @becameVisible() ) @intersectionObserver.observe(@domNode) From 0d8101b6c394ad260236dcc82020395f513e9837 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 8 Feb 2017 15:23:44 -0700 Subject: [PATCH 025/403] Synchronously call becameVisible if editor is visible when attached Signed-off-by: Max Brunsfeld --- src/text-editor-component.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/src/text-editor-component.coffee b/src/text-editor-component.coffee index d2ca1134778..502cd367082 100644 --- a/src/text-editor-component.coffee +++ b/src/text-editor-component.coffee @@ -133,6 +133,7 @@ class TextEditorComponent @becameVisible() ) @intersectionObserver.observe(@domNode) + @becameVisible() if @isVisible() measureDimensions = @measureDimensions.bind(this) elementResizeDetector.listenTo(@domNode, measureDimensions) From 0560ce7f18043a61ad9d4ffc49177f4da8151214 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 8 Feb 2017 15:28:21 -0700 Subject: [PATCH 026/403] Use window resize events to trigger measureWindowSize Signed-off-by: Max Brunsfeld --- src/text-editor-component.coffee | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/text-editor-component.coffee b/src/text-editor-component.coffee index 502cd367082..61a53059d7b 100644 --- a/src/text-editor-component.coffee +++ b/src/text-editor-component.coffee @@ -139,6 +139,10 @@ class TextEditorComponent elementResizeDetector.listenTo(@domNode, measureDimensions) @disposables.add(new Disposable => elementResizeDetector.removeListener(@domNode, measureDimensions)) + measureWindowSize = @measureWindowSize.bind(this) + window.addEventListener('resize', measureWindowSize) + @disposables.add(new Disposable => window.removeEventListener('resize', measureWindowSize)) + getDomNode: -> @domNode @@ -753,7 +757,6 @@ class TextEditorComponent pollDOM: => if @isVisible() @sampleBackgroundColors() - @measureWindowSize() @sampleFontStyling() @overlayManager?.measureOverlays() From 8bb3ec1563cb6389da8469b2f3ae753454a34bad Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 8 Feb 2017 15:48:07 -0700 Subject: [PATCH 027/403] Synchronously sample font styling when global editor stylesheet changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit And don’t sample font styling in pollDOM, which we are aiming to eliminate. Signed-off-by: Max Brunsfeld --- src/text-editor-component.coffee | 12 ++++++------ src/workspace-element.js | 1 - 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/text-editor-component.coffee b/src/text-editor-component.coffee index 61a53059d7b..9fcba1c19dd 100644 --- a/src/text-editor-component.coffee +++ b/src/text-editor-component.coffee @@ -649,11 +649,12 @@ class TextEditorComponent return unless @performedInitialMeasurement return unless @themes.isInitialLoadComplete() - # This delay prevents the styling from going haywire when stylesheets are - # reloaded in dev mode. It seems like a workaround for a browser bug, but - # not totally sure. - - unless @stylingChangeAnimationFrameRequested + # Handle styling change synchronously if a global editor property such as + # font size might have changed. Otherwise coalesce multiple style sheet changes + # into a measurement on the next animation frame to prevent excessive thrashing. + if styleElement.getAttribute('source-path') is 'global-text-editor-styles' + @handleStylingChange() + else if not @stylingChangeAnimationFrameRequested @stylingChangeAnimationFrameRequested = true requestAnimationFrame => @stylingChangeAnimationFrameRequested = false @@ -757,7 +758,6 @@ class TextEditorComponent pollDOM: => if @isVisible() @sampleBackgroundColors() - @sampleFontStyling() @overlayManager?.measureOverlays() # Measure explicitly-styled height and width and relay them to the model. If diff --git a/src/workspace-element.js b/src/workspace-element.js index 5d9b2803981..ae26decaef0 100644 --- a/src/workspace-element.js +++ b/src/workspace-element.js @@ -64,7 +64,6 @@ class WorkspaceElement extends HTMLElement { line-height: ${this.config.get('editor.lineHeight')}; }` this.styleManager.addStyleSheet(styleSheetSource, {sourcePath: 'global-text-editor-styles', priority: -1}) - this.viewRegistry.performDocumentPoll() } initialize (model, {config, project, styleManager, viewRegistry}) { From 549bce56378f7cc98a416b1b5799abd6cdce9b6c Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 8 Feb 2017 16:01:23 -0700 Subject: [PATCH 028/403] Replace sampleBackgroundColors with background-color: inherit in CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the shadow boundary made this impossible, but the new CSS is way simpler than the JS we’re replacing and removes another dependency on DOM polling. --- src/line-numbers-tile-component.coffee | 1 + src/lines-component.coffee | 1 + src/lines-tile-component.js | 1 + src/text-editor-component.coffee | 12 ------------ static/text-editor-light.less | 9 +++++++++ 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/line-numbers-tile-component.coffee b/src/line-numbers-tile-component.coffee index d552e20561b..ba62af3f84a 100644 --- a/src/line-numbers-tile-component.coffee +++ b/src/line-numbers-tile-component.coffee @@ -11,6 +11,7 @@ class LineNumbersTileComponent @domNode.style.position = "absolute" @domNode.style.display = "block" @domNode.style.top = 0 # Cover the space occupied by a dummy lineNumber + @domNode.style.backgroundColor = "inherit" destroy: -> @domElementPool.freeElementAndDescendants(@domNode) diff --git a/src/lines-component.coffee b/src/lines-component.coffee index 02d39602178..a3028771479 100644 --- a/src/lines-component.coffee +++ b/src/lines-component.coffee @@ -27,6 +27,7 @@ class LinesComponent extends TiledComponent # with other visual elements. @tilesNode.style.isolation = "isolate" @tilesNode.style.zIndex = 0 + @tilesNode.style.backgroundColor = "inherit" @domNode.appendChild(@tilesNode) @cursorsComponent = new CursorsComponent diff --git a/src/lines-tile-component.js b/src/lines-tile-component.js index 202e1670870..9bfdf738262 100644 --- a/src/lines-tile-component.js +++ b/src/lines-tile-component.js @@ -16,6 +16,7 @@ module.exports = class LinesTileComponent { this.domNode = this.domElementPool.buildElement('div') this.domNode.style.position = 'absolute' this.domNode.style.display = 'block' + this.domNode.style.backgroundColor = 'inherit' this.highlightsComponent = new HighlightsComponent(this.domElementPool) this.domNode.appendChild(this.highlightsComponent.getDomNode()) } diff --git a/src/text-editor-component.coffee b/src/text-editor-component.coffee index 9fcba1c19dd..861e1801469 100644 --- a/src/text-editor-component.coffee +++ b/src/text-editor-component.coffee @@ -220,7 +220,6 @@ class TextEditorComponent @invalidateMeasurements() @measureScrollbars() if @measureScrollbarsWhenShown @sampleFontStyling() - @sampleBackgroundColors() @measureWindowSize() @measureDimensions() @measureLineHeightAndDefaultCharWidth() if @measureLineHeightAndDefaultCharWidthWhenShown @@ -669,7 +668,6 @@ class TextEditorComponent handleStylingChange: => if @isVisible() @sampleFontStyling() - @sampleBackgroundColors() @invalidateMeasurements() handleDragUntilMouseUp: (dragHandler) -> @@ -757,7 +755,6 @@ class TextEditorComponent pollDOM: => if @isVisible() - @sampleBackgroundColors() @overlayManager?.measureOverlays() # Measure explicitly-styled height and width and relay them to the model. If @@ -824,15 +821,6 @@ class TextEditorComponent @measureLineHeightAndDefaultCharWidth() @invalidateMeasurements() - sampleBackgroundColors: (suppressUpdate) -> - {backgroundColor} = getComputedStyle(@hostElement) - @presenter.setBackgroundColor(backgroundColor) - - lineNumberGutter = @gutterContainerComponent?.getLineNumberGutterComponent() - if lineNumberGutter - gutterBackgroundColor = getComputedStyle(lineNumberGutter.getDomNode()).backgroundColor - @presenter.setGutterBackgroundColor(gutterBackgroundColor) - measureLineHeightAndDefaultCharWidth: -> if @isVisible() @measureLineHeightAndDefaultCharWidthWhenShown = false diff --git a/static/text-editor-light.less b/static/text-editor-light.less index 5f159ce941e..8683a402cf2 100644 --- a/static/text-editor-light.less +++ b/static/text-editor-light.less @@ -9,6 +9,7 @@ atom-text-editor { .editor--private, .editor-contents--private { height: 100%; width: 100%; + background-color: inherit; } .editor-contents--private { @@ -19,6 +20,10 @@ atom-text-editor { position: relative; } + .gutter-container { + background-color: inherit; + } + .gutter { overflow: hidden; z-index: 0; @@ -26,10 +31,12 @@ atom-text-editor { cursor: default; min-width: 1em; box-sizing: border-box; + background-color: inherit; } .line-numbers { position: relative; + background-color: inherit; } .line-number { @@ -86,6 +93,7 @@ atom-text-editor { flex: 1; min-width: 0; min-height: 0; + background-color: inherit; } .highlight { @@ -103,6 +111,7 @@ atom-text-editor { min-width: 100%; position: relative; z-index: 1; + background-color: inherit; } .line { From bbbb629f708d1b8262affb3e3e2bc46671623ae7 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 8 Feb 2017 18:11:21 -0700 Subject: [PATCH 029/403] Eliminate polling of overlay decoration dimensions And remove pollDOM method entirely. Signed-off-by: Max Brunsfeld --- src/overlay-manager.coffee | 15 ++++++++++----- src/text-editor-component.coffee | 7 ------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/overlay-manager.coffee b/src/overlay-manager.coffee index 83787ad10c4..b539a16c74a 100644 --- a/src/overlay-manager.coffee +++ b/src/overlay-manager.coffee @@ -1,3 +1,5 @@ +elementResizeDetector = require('element-resize-detector')({strategy: 'scroll'}) + module.exports = class OverlayManager constructor: (@presenter, @container, @views) -> @@ -12,6 +14,7 @@ class OverlayManager unless state.content.overlays.hasOwnProperty(id) delete @overlaysById[id] overlayNode.remove() + elementResizeDetector.uninstall(overlayNode) shouldUpdateOverlay: (decorationId, overlay) -> cachedOverlay = @overlaysById[decorationId] @@ -19,10 +22,6 @@ class OverlayManager cachedOverlay.pixelPosition?.top isnt overlay.pixelPosition?.top or cachedOverlay.pixelPosition?.left isnt overlay.pixelPosition?.left - measureOverlays: -> - for decorationId, {itemView} of @overlaysById - @measureOverlay(decorationId, itemView) - measureOverlay: (decorationId, itemView) -> contentMargin = parseInt(getComputedStyle(itemView)['margin-left']) ? 0 @presenter.setOverlayDimensions(decorationId, itemView.offsetWidth, itemView.offsetHeight, contentMargin) @@ -33,13 +32,19 @@ class OverlayManager unless overlayNode = cachedOverlay?.overlayNode overlayNode = document.createElement('atom-overlay') overlayNode.classList.add(klass) if klass? + elementResizeDetector.listenTo(overlayNode, => + if overlayNode.parentElement? + @measureOverlay(decorationId, itemView) + ) @container.appendChild(overlayNode) @overlaysById[decorationId] = cachedOverlay = {overlayNode, itemView} # The same node may be used in more than one overlay. This steals the node # back if it has been displayed in another overlay. - overlayNode.appendChild(itemView) if overlayNode.childNodes.length is 0 + overlayNode.appendChild(itemView) unless overlayNode.contains(itemView) cachedOverlay.pixelPosition = pixelPosition overlayNode.style.top = pixelPosition.top + 'px' overlayNode.style.left = pixelPosition.left + 'px' + + @measureOverlay(decorationId, itemView) diff --git a/src/text-editor-component.coffee b/src/text-editor-component.coffee index 861e1801469..33e5ae6130c 100644 --- a/src/text-editor-component.coffee +++ b/src/text-editor-component.coffee @@ -106,8 +106,6 @@ class TextEditorComponent @disposables.add @themes.onDidChangeActiveThemes @onAllThemesLoaded @disposables.add scrollbarStyle.onDidChangePreferredScrollbarStyle @refreshScrollbars - @disposables.add @views.pollDocument(@pollDOM) - @updateSync() @initialized = true @@ -204,7 +202,6 @@ class TextEditorComponent @linesComponent.updateSync(@presenter.getPreMeasurementState()) readAfterUpdateSync: => - @overlayManager?.measureOverlays() @linesComponent.measureBlockDecorations() @offScreenBlockDecorationsComponent.measureBlockDecorations() @@ -753,10 +750,6 @@ class TextEditorComponent @domNode? and (@domNode.offsetHeight > 0 or @domNode.offsetWidth > 0) - pollDOM: => - if @isVisible() - @overlayManager?.measureOverlays() - # Measure explicitly-styled height and width and relay them to the model. If # these values aren't explicitly styled, we assume the editor is unconstrained # and use the scrollHeight / scrollWidth as its height and width in From a0c84d592a04a9419e985861572f7e35536fe40a Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sat, 11 Feb 2017 09:26:55 -0700 Subject: [PATCH 030/403] Remove ViewRegistry.pollDocument Supporting it via mutation observers has a bad impact on frame rate. --- spec/text-editor-component-spec.js | 10 ---- spec/view-registry-spec.coffee | 90 ------------------------------ src/atom-environment.coffee | 2 - src/view-registry.coffee | 43 -------------- 4 files changed, 145 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 35d2af832e3..1daeb34e98c 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -57,7 +57,6 @@ describe('TextEditorComponent', function () { horizontalScrollbarNode = componentNode.querySelector('.horizontal-scrollbar') component.measureDimensions() - advanceClock(atom.views.minimumPollInterval) runAnimationFrames(true) }) @@ -395,9 +394,6 @@ describe('TextEditorComponent', function () { } wrapperNode.style.backgroundColor = 'rgb(255, 0, 0)' - // Polling should happen automatically in a mutation observer but its async - // and everything is mocked to be sync - atom.views.performDocumentPoll() runAnimationFrames(true) expect(linesNode.style.backgroundColor).toBe('rgb(255, 0, 0)') @@ -985,7 +981,6 @@ describe('TextEditorComponent', function () { } gutterNode.style.backgroundColor = 'rgb(255, 0, 0)' - atom.views.performDocumentPoll() runAnimationFrames() expect(lineNumbersNode.style.backgroundColor).toBe('rgb(255, 0, 0)') @@ -2336,7 +2331,6 @@ describe('TextEditorComponent', function () { return window.innerWidth !== innerWidthBefore }) - atom.views.performDocumentPoll() runAnimationFrames() expect(overlay.style.left).toBe(window.innerWidth - itemWidth + 'px') @@ -4284,7 +4278,6 @@ describe('TextEditorComponent', function () { componentNode = component.getDomNode() expect(componentNode.querySelectorAll('.line').length).toBe(0) hiddenParent.style.display = 'block' - atom.views.performDocumentPoll() expect(componentNode.querySelectorAll('.line').length).toBeGreaterThan(0) }) }) @@ -4391,13 +4384,11 @@ describe('TextEditorComponent', function () { expect(parseInt(newHeight)).toBeLessThan(wrapperNode.offsetHeight) wrapperNode.style.height = newHeight editor.update({autoHeight: false}) - atom.views.performDocumentPoll() runAnimationFrames() expect(componentNode.querySelectorAll('.line')).toHaveLength(7) let gutterWidth = componentNode.querySelector('.gutter').offsetWidth componentNode.style.width = gutterWidth + 14 * charWidth + wrapperNode.getVerticalScrollbarWidth() + 'px' - atom.views.performDocumentPoll() runAnimationFrames() expect(componentNode.querySelector('.line').textContent).toBe('var quicksort ') }) @@ -4406,7 +4397,6 @@ describe('TextEditorComponent', function () { let scrollViewNode = componentNode.querySelector('.scroll-view') scrollViewNode.style.paddingLeft = 20 + 'px' componentNode.style.width = 30 * charWidth + 'px' - atom.views.performDocumentPoll() runAnimationFrames() expect(component.lineNodeForScreenRow(0).textContent).toBe('var quicksort = ') }) diff --git a/spec/view-registry-spec.coffee b/spec/view-registry-spec.coffee index d805d17cb6d..c650184e298 100644 --- a/spec/view-registry-spec.coffee +++ b/spec/view-registry-spec.coffee @@ -127,8 +127,6 @@ describe "ViewRegistry", -> spyOn(window, 'clearInterval').andCallFake(fakeClearInterval) events = [] - registry.pollDocument -> events.push('poll') - registry.pollAfterNextUpdate() registry.updateDocument -> events.push('write 1') registry.readDocument -> registry.updateDocument -> events.push('write from read 1') @@ -147,108 +145,20 @@ describe "ViewRegistry", -> 'write 2' 'read 1' 'read 2' - 'poll' 'write from read 1' 'write from read 2' ] - it "pauses DOM polling when reads or writes are pending", -> - spyOn(window, 'setInterval').andCallFake(fakeSetInterval) - spyOn(window, 'clearInterval').andCallFake(fakeClearInterval) - events = [] - - registry.pollDocument -> events.push('poll') - registry.updateDocument -> events.push('write') - registry.readDocument -> events.push('read') - - window.dispatchEvent(new UIEvent('resize')) - expect(events).toEqual [] - - frameRequests[0]() - expect(events).toEqual ['write', 'read', 'poll'] - - window.dispatchEvent(new UIEvent('resize')) - expect(events).toEqual ['write', 'read', 'poll', 'poll'] - - it "polls the document after updating when ::pollAfterNextUpdate() has been called", -> - events = [] - registry.pollDocument -> events.push('poll') - registry.updateDocument -> events.push('write') - registry.readDocument -> events.push('read') - frameRequests.shift()() - expect(events).toEqual ['write', 'read'] - - events = [] - registry.pollAfterNextUpdate() - registry.updateDocument -> events.push('write') - registry.readDocument -> events.push('read') - frameRequests.shift()() - expect(events).toEqual ['write', 'read', 'poll'] - - describe "::pollDocument(fn)", -> - [testElement, testStyleSheet, disposable1, disposable2, events] = [] - - beforeEach -> - testElement = document.createElement('div') - testStyleSheet = document.createElement('style') - testStyleSheet.textContent = 'body {}' - jasmineContent = document.getElementById('jasmine-content') - jasmineContent.appendChild(testElement) - jasmineContent.appendChild(testStyleSheet) - - events = [] - disposable1 = registry.pollDocument -> events.push('poll 1') - disposable2 = registry.pollDocument -> events.push('poll 2') - - it "calls all registered polling functions after document or stylesheet changes until they are disabled via a returned disposable", -> - jasmine.useRealClock() - expect(events).toEqual [] - - testElement.style.width = '400px' - - waitsFor "events to occur in response to DOM mutation", -> events.length > 0 - - runs -> - expect(events).toEqual ['poll 1', 'poll 2'] - events.length = 0 - - testStyleSheet.textContent = 'body {color: #333;}' - - waitsFor "events to occur in reponse to style sheet mutation", -> events.length > 0 - - runs -> - expect(events).toEqual ['poll 1', 'poll 2'] - events.length = 0 - - disposable1.dispose() - testElement.style.color = '#fff' - - waitsFor "more events to occur in response to DOM mutation", -> events.length > 0 - - runs -> - expect(events).toEqual ['poll 2'] - - it "calls all registered polling functions when the window resizes", -> - expect(events).toEqual [] - - window.dispatchEvent(new UIEvent('resize')) - - expect(events).toEqual ['poll 1', 'poll 2'] - describe "::getNextUpdatePromise()", -> it "returns a promise that resolves at the end of the next update cycle", -> updateCalled = false readCalled = false - pollCalled = false waitsFor 'getNextUpdatePromise to resolve', (done) -> registry.getNextUpdatePromise().then -> expect(updateCalled).toBe true expect(readCalled).toBe true - expect(pollCalled).toBe true done() registry.updateDocument -> updateCalled = true registry.readDocument -> readCalled = true - registry.pollDocument -> pollCalled = true - registry.pollAfterNextUpdate() diff --git a/src/atom-environment.coffee b/src/atom-environment.coffee index c7843f7e8ab..8ed491c159c 100644 --- a/src/atom-environment.coffee +++ b/src/atom-environment.coffee @@ -208,8 +208,6 @@ class AtomEnvironment extends Model @getStorageFolder().clear() @stateStore.clear() - @views.initialize() - ConfigSchema.projectHome = { type: 'string', default: path.join(fs.getHomeDirectory(), 'github'), diff --git a/src/view-registry.coffee b/src/view-registry.coffee index b8ead971770..f300cc03130 100644 --- a/src/view-registry.coffee +++ b/src/view-registry.coffee @@ -27,21 +27,13 @@ module.exports = class ViewRegistry animationFrameRequest: null documentReadInProgress: false - performDocumentPollAfterUpdate: false - debouncedPerformDocumentPoll: null - minimumPollInterval: 200 constructor: (@atomEnvironment) -> - @polling = false @clear() - initialize: -> - @observer = new MutationObserver(@requestDocumentPoll) - clear: -> @views = new WeakMap @providers = [] - @debouncedPerformDocumentPoll = _.throttle(@performDocumentPoll, @minimumPollInterval).bind(this) @clearDocumentRequests() # Essential: Add a provider that will be used to construct views in the @@ -175,16 +167,6 @@ class ViewRegistry new Disposable => @documentReaders = @documentReaders.filter (reader) -> reader isnt fn - pollDocument: (fn) -> - @startPollingDocument() if @documentPollers.length is 0 - @documentPollers.push(fn) - new Disposable => - @documentPollers = @documentPollers.filter (poller) -> poller isnt fn - @stopPollingDocument() if @documentPollers.length is 0 - - pollAfterNextUpdate: -> - @performDocumentPollAfterUpdate = true - getNextUpdatePromise: -> @nextUpdatePromise ?= new Promise (resolve) => @resolveNextUpdatePromise = resolve @@ -192,13 +174,11 @@ class ViewRegistry clearDocumentRequests: -> @documentReaders = [] @documentWriters = [] - @documentPollers = [] @nextUpdatePromise = null @resolveNextUpdatePromise = null if @animationFrameRequest? cancelAnimationFrame(@animationFrameRequest) @animationFrameRequest = null - @stopPollingDocument() requestDocumentUpdate: -> @animationFrameRequest ?= requestAnimationFrame(@performDocumentUpdate) @@ -213,32 +193,9 @@ class ViewRegistry @documentReadInProgress = true reader() while reader = @documentReaders.shift() - @performDocumentPoll() if @performDocumentPollAfterUpdate - @performDocumentPollAfterUpdate = false @documentReadInProgress = false # process updates requested as a result of reads writer() while writer = @documentWriters.shift() resolveNextUpdatePromise?() - - startPollingDocument: -> - window.addEventListener('resize', @requestDocumentPoll) - @observer.observe(document, {subtree: true, childList: true, attributes: true}) - @polling = true - - stopPollingDocument: -> - if @polling - window.removeEventListener('resize', @requestDocumentPoll) - @observer.disconnect() - @polling = false - - requestDocumentPoll: => - if @animationFrameRequest? - @performDocumentPollAfterUpdate = true - else - @debouncedPerformDocumentPoll() - - performDocumentPoll: -> - poller() for poller in @documentPollers - return From 1c796ed773e91b8382159969bbb3e9c64fe6545d Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sat, 11 Feb 2017 09:25:52 -0700 Subject: [PATCH 031/403] :arrow_up: donna in script dependencies --- script/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/package.json b/script/package.json index c4b20a63fc5..b4d3b2c9a2e 100644 --- a/script/package.json +++ b/script/package.json @@ -7,7 +7,7 @@ "coffeelint": "1.15.7", "colors": "1.1.2", "csslint": "1.0.2", - "donna": "1.0.13", + "donna": "1.0.16", "electron-chromedriver": "~1.4", "electron-link": "0.0.23", "electron-mksnapshot": "~1.3", From a7236147507592f9b8fdebddd02d61e68fefc6cd Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 14 Feb 2017 10:18:55 -0700 Subject: [PATCH 032/403] Fix lint error --- src/text-editor-component.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/text-editor-component.coffee b/src/text-editor-component.coffee index 33e5ae6130c..2cf15ad72f4 100644 --- a/src/text-editor-component.coffee +++ b/src/text-editor-component.coffee @@ -139,7 +139,7 @@ class TextEditorComponent measureWindowSize = @measureWindowSize.bind(this) window.addEventListener('resize', measureWindowSize) - @disposables.add(new Disposable => window.removeEventListener('resize', measureWindowSize)) + @disposables.add(new Disposable -> window.removeEventListener('resize', measureWindowSize)) getDomNode: -> @domNode From b6e3806e7725307bd925ab53c6c1a7f4be5ffe77 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 16 Mar 2017 15:56:26 -0600 Subject: [PATCH 033/403] Instantiate elementResizeDetector instances lazily This avoids references to browser globals during snapshot creation. --- src/overlay-manager.coffee | 4 +++- src/text-editor-component.coffee | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/overlay-manager.coffee b/src/overlay-manager.coffee index b539a16c74a..c8c7f4b275c 100644 --- a/src/overlay-manager.coffee +++ b/src/overlay-manager.coffee @@ -1,4 +1,5 @@ -elementResizeDetector = require('element-resize-detector')({strategy: 'scroll'}) +ElementResizeDetector = require('element-resize-detector') +elementResizeDetector = null module.exports = class OverlayManager @@ -32,6 +33,7 @@ class OverlayManager unless overlayNode = cachedOverlay?.overlayNode overlayNode = document.createElement('atom-overlay') overlayNode.classList.add(klass) if klass? + elementResizeDetector ?= ElementResizeDetector({strategy: 'scroll'}) elementResizeDetector.listenTo(overlayNode, => if overlayNode.parentElement? @measureOverlay(decorationId, itemView) diff --git a/src/text-editor-component.coffee b/src/text-editor-component.coffee index 2cf15ad72f4..e38030b91d4 100644 --- a/src/text-editor-component.coffee +++ b/src/text-editor-component.coffee @@ -3,7 +3,8 @@ scrollbarStyle = require 'scrollbar-style' {CompositeDisposable, Disposable} = require 'event-kit' {ipcRenderer} = require 'electron' Grim = require 'grim' -elementResizeDetector = require('element-resize-detector')({strategy: 'scroll'}) +ElementResizeDetector = require('element-resize-detector') +elementResizeDetector = null TextEditorPresenter = require './text-editor-presenter' GutterContainerComponent = require './gutter-container-component' @@ -134,6 +135,7 @@ class TextEditorComponent @becameVisible() if @isVisible() measureDimensions = @measureDimensions.bind(this) + elementResizeDetector ?= ElementResizeDetector({strategy: 'scroll'}) elementResizeDetector.listenTo(@domNode, measureDimensions) @disposables.add(new Disposable => elementResizeDetector.removeListener(@domNode, measureDimensions)) From cb982d65138fba69a69a0f4cbfd59ec67c383e2d Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 1 Mar 2017 20:20:51 -0700 Subject: [PATCH 034/403] Upgrade to Electron 1.6.2 --- package.json | 2 +- script/package.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 073d0da06c2..cdedb4c6f8a 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/atom/atom/issues" }, "license": "MIT", - "electronVersion": "1.4.15", + "electronVersion": "1.6.2", "dependencies": { "async": "0.2.6", "atom-keymap": "8.1.2", diff --git a/script/package.json b/script/package.json index b4d3b2c9a2e..f8c9d17764f 100644 --- a/script/package.json +++ b/script/package.json @@ -8,9 +8,9 @@ "colors": "1.1.2", "csslint": "1.0.2", "donna": "1.0.16", - "electron-chromedriver": "~1.4", + "electron-chromedriver": "~1.6", "electron-link": "0.0.23", - "electron-mksnapshot": "~1.3", + "electron-mksnapshot": "~1.6", "electron-packager": "7.3.0", "electron-winstaller": "2.5.1", "fs-extra": "0.30.0", From 06774dbff8c4fb3a5bfbb98930f859a91163c7cf Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 1 Mar 2017 20:55:20 -0700 Subject: [PATCH 035/403] Call compiled functions with Buffer for Electron 1.6 compatibility --- src/native-compile-cache.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/native-compile-cache.js b/src/native-compile-cache.js index 09a62b186e4..cc947e84b12 100644 --- a/src/native-compile-cache.js +++ b/src/native-compile-cache.js @@ -83,7 +83,7 @@ class NativeCompileCache { compiledWrapper = compilationResult.result } - let args = [moduleSelf.exports, require, moduleSelf, filename, dirname, process, global] + let args = [moduleSelf.exports, require, moduleSelf, filename, dirname, process, global, Buffer] return compiledWrapper.apply(moduleSelf.exports, args) } } From c28685c816d1f71a81fabb6321f1e05c9a50aa8f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 16 Mar 2017 20:22:39 -0600 Subject: [PATCH 036/403] Don't use --eval when forking child process in Task There seems to be an Electron bug or something where the child process isn't receiving messages when --eval is used. It isn't necessary anyway if we we convert task-bootstrap.coffee to vanilla JS. --- src/task-bootstrap.coffee | 54 ----------------------------- src/task-bootstrap.js | 72 +++++++++++++++++++++++++++++++++++++++ src/task.coffee | 19 ++--------- 3 files changed, 74 insertions(+), 71 deletions(-) delete mode 100644 src/task-bootstrap.coffee create mode 100644 src/task-bootstrap.js diff --git a/src/task-bootstrap.coffee b/src/task-bootstrap.coffee deleted file mode 100644 index ebb5cdc2b03..00000000000 --- a/src/task-bootstrap.coffee +++ /dev/null @@ -1,54 +0,0 @@ -{userAgent, taskPath} = process.env -handler = null - -setupGlobals = -> - global.attachEvent = -> - console = - warn: -> emit 'task:warn', arguments... - log: -> emit 'task:log', arguments... - error: -> emit 'task:error', arguments... - trace: -> - global.__defineGetter__ 'console', -> console - - global.document = - createElement: -> - setAttribute: -> - getElementsByTagName: -> [] - appendChild: -> - documentElement: - insertBefore: -> - removeChild: -> - getElementById: -> {} - createComment: -> {} - createDocumentFragment: -> {} - - global.emit = (event, args...) -> - process.send({event, args}) - global.navigator = {userAgent} - global.window = global - -handleEvents = -> - process.on 'uncaughtException', (error) -> - console.error(error.message, error.stack) - process.on 'message', ({event, args}={}) -> - return unless event is 'start' - - isAsync = false - async = -> - isAsync = true - (result) -> - emit('task:completed', result) - result = handler.bind({async})(args...) - emit('task:completed', result) unless isAsync - -setupDeprecations = -> - Grim = require 'grim' - Grim.on 'updated', -> - deprecations = Grim.getDeprecations().map (deprecation) -> deprecation.serialize() - Grim.clearDeprecations() - emit('task:deprecations', deprecations) - -setupGlobals() -handleEvents() -setupDeprecations() -handler = require(taskPath) diff --git a/src/task-bootstrap.js b/src/task-bootstrap.js new file mode 100644 index 00000000000..862b6d4acf3 --- /dev/null +++ b/src/task-bootstrap.js @@ -0,0 +1,72 @@ +if (typeof snapshotResult !== 'undefined') { + snapshotResult.setGlobals(global, process, global, {}, console, require) +} + +const {userAgent} = process.env +const [compileCachePath, taskPath] = process.argv.slice(2) + +const CompileCache = require('./compile-cache') +CompileCache.setCacheDirectory(compileCachePath); +CompileCache.install(`${process.resourcesPath}`, require) + +const setupGlobals = function () { + global.attachEvent = function () {} + const console = { + warn () { return global.emit('task:warn', ...arguments) }, + log () { return global.emit('task:log', ...arguments) }, + error () { return global.emit('task:error', ...arguments) }, + trace () {} + } + global.__defineGetter__('console', () => console) + + global.document = { + createElement () { + return { + setAttribute () {}, + getElementsByTagName () { return [] }, + appendChild () {} + } + }, + documentElement: { + insertBefore () {}, + removeChild () {} + }, + getElementById () { return {} }, + createComment () { return {} }, + createDocumentFragment () { return {} } + } + + global.emit = (event, ...args) => process.send({event, args}) + global.navigator = {userAgent} + return (global.window = global) +} + +const handleEvents = function () { + process.on('uncaughtException', error => console.error(error.message, error.stack)) + + return process.on('message', function ({event, args} = {}) { + if (event !== 'start') { return } + + let isAsync = false + const async = function () { + isAsync = true + return result => global.emit('task:completed', result) + } + const result = handler.bind({async})(...args) + if (!isAsync) { return global.emit('task:completed', result) } + }) +} + +const setupDeprecations = function () { + const Grim = require('grim') + return Grim.on('updated', function () { + const deprecations = Grim.getDeprecations().map(deprecation => deprecation.serialize()) + Grim.clearDeprecations() + return global.emit('task:deprecations', deprecations) + }) +} + +setupGlobals() +handleEvents() +setupDeprecations() +const handler = require(taskPath) diff --git a/src/task.coffee b/src/task.coffee index 0014f740793..b8283cb01b7 100644 --- a/src/task.coffee +++ b/src/task.coffee @@ -66,26 +66,11 @@ class Task constructor: (taskPath) -> @emitter = new Emitter - compileCacheRequire = "require('#{require.resolve('./compile-cache')}')" compileCachePath = require('./compile-cache').getCacheDirectory() - taskBootstrapRequire = "require('#{require.resolve('./task-bootstrap')}');" - bootstrap = """ - if (typeof snapshotResult !== 'undefined') { - snapshotResult.setGlobals(global, process, global, {}, console, require) - } - - CompileCache = #{compileCacheRequire} - CompileCache.setCacheDirectory('#{compileCachePath}'); - CompileCache.install("#{process.resourcesPath}", require) - #{taskBootstrapRequire} - """ - bootstrap = bootstrap.replace(/\\/g, "\\\\") - taskPath = require.resolve(taskPath) - taskPath = taskPath.replace(/\\/g, "\\\\") - env = _.extend({}, process.env, {taskPath, userAgent: navigator.userAgent}) - @childProcess = ChildProcess.fork '--eval', [bootstrap], {env, silent: true} + env = Object.assign({}, process.env, {userAgent: navigator.userAgent}) + @childProcess = ChildProcess.fork require.resolve('./task-bootstrap'), [compileCachePath, taskPath], {env, silent: true} @on "task:log", -> console.log(arguments...) @on "task:warn", -> console.warn(arguments...) From 4548fe83584ac331b1390e3f1815dfee2c29d983 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 16 Mar 2017 20:23:10 -0600 Subject: [PATCH 037/403] Upgrade to Electron 1.6.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cdedb4c6f8a..65e64d0075c 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/atom/atom/issues" }, "license": "MIT", - "electronVersion": "1.6.2", + "electronVersion": "1.6.3", "dependencies": { "async": "0.2.6", "atom-keymap": "8.1.2", From 631861a3660f1e39dfbffdff08cc3de785fe98a2 Mon Sep 17 00:00:00 2001 From: Wliu Date: Wed, 22 Mar 2017 22:55:50 -0400 Subject: [PATCH 038/403] :arrow_up: electron@1.6.4 For Windows sourcemap support --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 65e64d0075c..2ae871a17a0 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/atom/atom/issues" }, "license": "MIT", - "electronVersion": "1.6.3", + "electronVersion": "1.6.4", "dependencies": { "async": "0.2.6", "atom-keymap": "8.1.2", From 32db6a46dc36a18bf3363997129daefbecc5605b Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 30 Mar 2017 11:12:20 +0200 Subject: [PATCH 039/403] Fix CSP for fonts --- static/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/index.html b/static/index.html index 39c7d80c1fb..4e23e495219 100644 --- a/static/index.html +++ b/static/index.html @@ -1,7 +1,7 @@ - + From 0bd1255b15ceea8a76312536a957621597dcd986 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 30 Mar 2017 11:37:25 +0200 Subject: [PATCH 040/403] Solve CSP issues for all media types --- static/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/index.html b/static/index.html index 4e23e495219..386481bb55c 100644 --- a/static/index.html +++ b/static/index.html @@ -1,7 +1,7 @@ - + From d9004769e0574bc7e3576eb2eb4a79944bc42860 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 31 Mar 2017 13:50:29 +0200 Subject: [PATCH 041/403] Disable auxclick --- src/main-process/atom-window.coffee | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main-process/atom-window.coffee b/src/main-process/atom-window.coffee index 518fd63e8f4..4ccdb92a14f 100644 --- a/src/main-process/atom-window.coffee +++ b/src/main-process/atom-window.coffee @@ -32,6 +32,10 @@ class AtomWindow # local development experience when running specs through the UI (which # now won't pause when e.g. minimizing the window). backgroundThrottling: not @isSpec + # Disable the `auxclick` feature so that `click` events are triggered in + # response to a middle-click. + # (Ref: https://github.com/atom/atom/pull/12696#issuecomment-290496960) + disableBlinkFeatures: 'Auxclick' # Don't set icon on Windows so the exe's ico will be used as window and # taskbar's icon. See https://github.com/atom/atom/issues/4811 for more. From 9bc53948b7bd61c435201c31ddf21c78ae7f9401 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 31 Mar 2017 15:10:03 -0600 Subject: [PATCH 042/403] :arrow_up: electron to 1.6.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2ae871a17a0..8073c4aef78 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/atom/atom/issues" }, "license": "MIT", - "electronVersion": "1.6.4", + "electronVersion": "1.6.5", "dependencies": { "async": "0.2.6", "atom-keymap": "8.1.2", From c638f8693a7de7dc6667e60a93265d843c8428eb Mon Sep 17 00:00:00 2001 From: Wliu Date: Fri, 7 Apr 2017 23:10:57 -0400 Subject: [PATCH 043/403] Update spec reporter relativization Electron 1.6 changed the reporting from . to jasmine.Spec. --- spec/atom-reporter.coffee | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/atom-reporter.coffee b/spec/atom-reporter.coffee index a91059efe0f..455afcb2743 100644 --- a/spec/atom-reporter.coffee +++ b/spec/atom-reporter.coffee @@ -21,8 +21,8 @@ formatStackTrace = (spec, message='', stackTrace) -> lines.shift() if message.trim() is errorMatch?[1]?.trim() for line, index in lines - # Remove prefix of lines matching: at . (path:1:2) - prefixMatch = line.match(/at \. \(([^)]+)\)/) + # Remove prefix of lines matching: at jasmine.Spec. (path:1:2) + prefixMatch = line.match(/at jasmine\.Spec\. \(([^)]+)\)/) line = "at #{prefixMatch[1]}" if prefixMatch # Relativize locations to spec directory From 1f068bde629b8748c7e409e53d56dfadea22108d Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 13 Apr 2017 12:53:56 -0600 Subject: [PATCH 044/403] Enable experimental web platform features This will facilitate #13880 --- src/main-process/start.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main-process/start.js b/src/main-process/start.js index d711d5f6479..fae78a07e92 100644 --- a/src/main-process/start.js +++ b/src/main-process/start.js @@ -22,6 +22,8 @@ module.exports = function start (resourcePath, startTime) { const previousConsoleLog = console.log console.log = nslog + app.commandLine.appendSwitch('enable-experimental-web-platform-features') + const args = parseCommandLine(process.argv.slice(1)) atomPaths.setAtomHome(app.getPath('home')) atomPaths.setUserData(app) From f237d7035776d149ec8cd30377b555d247a82032 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 21 Feb 2017 17:32:27 -0700 Subject: [PATCH 045/403] WIP --- ...offee => text-editor-component-old.coffee} | 0 src/text-editor-component.js | 321 ++++++++++++++++++ ....coffee => text-editor-element-old.coffee} | 0 src/text-editor-element.js | 57 ++++ src/text-editor.coffee | 5 +- static/atom.less | 1 + static/text-editor-light.less | 140 ++++---- static/text-editor.less | 50 +++ 8 files changed, 501 insertions(+), 73 deletions(-) rename src/{text-editor-component.coffee => text-editor-component-old.coffee} (100%) create mode 100644 src/text-editor-component.js rename src/{text-editor-element.coffee => text-editor-element-old.coffee} (100%) create mode 100644 src/text-editor-element.js create mode 100644 static/text-editor.less diff --git a/src/text-editor-component.coffee b/src/text-editor-component-old.coffee similarity index 100% rename from src/text-editor-component.coffee rename to src/text-editor-component-old.coffee diff --git a/src/text-editor-component.js b/src/text-editor-component.js new file mode 100644 index 00000000000..7cd9cd2b388 --- /dev/null +++ b/src/text-editor-component.js @@ -0,0 +1,321 @@ +const etch = require('etch') +const $ = etch.dom +const TextEditorElement = require('./text-editor-element') + +const ROWS_PER_TILE = 6 +const NORMAL_WIDTH_CHARACTER = 'x' +const DOUBLE_WIDTH_CHARACTER = '我' +const HALF_WIDTH_CHARACTER = 'ハ' +const KOREAN_CHARACTER = '세' + +const characterMeasurementSpans = {} +const characterMeasurementLineNode = etch.render($.div({className: 'line'}, + $.span({ref: 'normalWidthCharacterSpan'}, NORMAL_WIDTH_CHARACTER), + $.span({ref: 'doubleWidthCharacterSpan'}, DOUBLE_WIDTH_CHARACTER), + $.span({ref: 'halfWidthCharacterSpan'}, HALF_WIDTH_CHARACTER), + $.span({ref: 'koreanCharacterSpan'}, KOREAN_CHARACTER) +), {refs: characterMeasurementSpans}) + +module.exports = +class TextEditorComponent { + constructor (props) { + this.props = props + this.element = props.element || new TextEditorElement() + this.element.initialize(this) + this.virtualNode = $('atom-text-editor') + this.virtualNode.domNode = this.element + this.refs = {} + etch.updateSync(this) + } + + update (props) { + } + + updateSync () { + etch.updateSync(this) + } + + render () { + return $('atom-text-editor', null, + $.div({ref: 'scroller', onScroll: this.didScroll, className: 'scroll-view'}, + this.renderGutterContainer(), + this.renderLines() + ) + ) + } + + renderGutterContainer () { + return $.div({className: 'gutter-container'}, + this.measurements ? this.renderLineNumberGutter() : [] + ) + } + + renderLineNumberGutter () { + const maxLineNumberDigits = Math.max(2, this.getModel().getLineCount().toString().length) + + const firstTileStartRow = this.getTileStartRow(this.getFirstVisibleRow()) + const lastTileStartRow = this.getTileStartRow(this.getLastVisibleRow()) + + console.log({firstTileStartRow, lastTileStartRow}); + + let tileNodes = [] + + let currentTileStaticTop = 0 + let previousBufferRow = (firstTileStartRow > 0) ? this.getModel().bufferRowForScreenRow(firstTileStartRow - 1) : -1 + for (let tileStartRow = firstTileStartRow; tileStartRow <= lastTileStartRow; tileStartRow += ROWS_PER_TILE) { + const currentTileEndRow = tileStartRow + ROWS_PER_TILE + const lineNumberNodes = [] + + for (let row = tileStartRow; row < currentTileEndRow; row++) { + const bufferRow = this.getModel().bufferRowForScreenRow(row) + const foldable = this.getModel().isFoldableAtBufferRow(bufferRow) + const softWrapped = (bufferRow === previousBufferRow) + + let className = 'line-number' + let lineNumber + if (softWrapped) { + lineNumber = '•' + } else { + if (foldable) className += ' foldable' + lineNumber = (bufferRow + 1).toString() + } + lineNumber = '\u00a0'.repeat(maxLineNumberDigits - lineNumber.length) + lineNumber + + lineNumberNodes.push($.div({className}, + lineNumber, + $.div({className: 'icon-right'}) + )) + + previousBufferRow = bufferRow + } + + const tileHeight = ROWS_PER_TILE * this.measurements.lineHeight + const yTranslation = this.topPixelPositionForRow(tileStartRow) - currentTileStaticTop + + tileNodes.push($.div({ + style: { + height: tileHeight + 'px', + width: 'min-content', + transform: `translateY(${yTranslation}px)`, + backgroundColor: 'inherit', + } + }, lineNumberNodes)) + + currentTileStaticTop += tileHeight + } + + return $.div({className: 'gutter line-numbers', 'gutter-name': 'line-number'}, tileNodes) + } + + renderLines () { + const style = (this.measurements) + ? { + width: this.measurements.scrollWidth + 'px', + height: this.getScrollHeight() + 'px' + } : null + + return $.div({ref: 'lines', className: 'lines', style}, this.renderLineTiles()) + } + + renderLineTiles () { + if (!this.measurements) return [] + + const firstTileStartRow = this.getTileStartRow(this.getFirstVisibleRow()) + const lastTileStartRow = this.getTileStartRow(this.getLastVisibleRow()) + const visibleTileCount = lastTileStartRow - firstTileStartRow + 1 + const displayLayer = this.getModel().displayLayer + const screenLines = displayLayer.getScreenLines(firstTileStartRow, lastTileStartRow + ROWS_PER_TILE) + + console.log({ + firstVisible: this.getFirstVisibleRow(), + lastVisible: this.getLastVisibleRow(), + firstTileStartRow, lastTileStartRow + }); + + let tileNodes = [] + for (let tileStartRow = firstTileStartRow; tileStartRow <= lastTileStartRow; tileStartRow += ROWS_PER_TILE) { + const tileEndRow = tileStartRow + ROWS_PER_TILE + const lineNodes = [] + for (let row = tileStartRow; row < tileEndRow; row++) { + const screenLine = screenLines[row - firstTileStartRow] + if (!screenLine) break + lineNodes.push($(LineComponent, {key: screenLine.id, displayLayer, screenLine})) + } + + const tileHeight = ROWS_PER_TILE * this.measurements.lineHeight + + tileNodes.push($.div({ + key: (tileStartRow / ROWS_PER_TILE) % visibleTileCount, + style: { + position: 'absolute', + height: tileHeight + 'px', + width: this.measurements.scrollWidth + 'px', + transform: `translateY(${this.topPixelPositionForRow(tileStartRow)}px)`, + backgroundColor: 'inherit' + } + }, lineNodes)) + } + + return tileNodes + } + + didAttach () { + this.intersectionObserver = new IntersectionObserver((entries) => { + const {intersectionRect} = entries[entries.length - 1] + if (intersectionRect.width > 0 || intersectionRect.height > 0) { + this.didShow() + } + }) + this.intersectionObserver.observe(this.element) + if (this.isVisible()) this.didShow() + } + + didShow () { + if (!this.measurements) this.performInitialMeasurements() + etch.updateSync(this) + } + + didScroll () { + this.measureScrollPosition() + this.updateSync() + } + + performInitialMeasurements () { + this.measurements = {} + this.measureEditorDimensions() + this.measureScrollPosition() + this.measureCharacterDimensions() + this.measureLongestLineWidth() + } + + measureEditorDimensions () { + this.measurements.scrollerHeight = this.refs.scroller.offsetHeight + } + + measureScrollPosition () { + this.measurements.scrollTop = this.refs.scroller.scrollTop + this.measurements.scrollLeft = this.refs.scroller.scrollLeft + } + + measureCharacterDimensions () { + this.refs.lines.appendChild(characterMeasurementLineNode) + this.measurements.lineHeight = characterMeasurementLineNode.getBoundingClientRect().height + this.measurements.baseCharacterWidth = characterMeasurementSpans.normalWidthCharacterSpan.getBoundingClientRect().width + this.measurements.doubleWidthCharacterWidth = characterMeasurementSpans.doubleWidthCharacterSpan.getBoundingClientRect().width + this.measurements.halfWidthCharacterWidth = characterMeasurementSpans.halfWidthCharacterSpan.getBoundingClientRect().width + this.measurements.koreanCharacterWidth = characterMeasurementSpans.koreanCharacterSpan.getBoundingClientRect().widt + this.refs.lines.removeChild(characterMeasurementLineNode) + } + + measureLongestLineWidth () { + const displayLayer = this.getModel().displayLayer + const rightmostPosition = displayLayer.getApproximateRightmostScreenPosition() + this.measurements.scrollWidth = rightmostPosition.column * this.measurements.baseCharacterWidth + } + + getModel () { + if (!this.props.model) { + const TextEditor = require('./text-editor') + this.props.model = new TextEditor() + } + return this.props.model + } + + isVisible () { + return this.element.offsetWidth > 0 || this.element.offsetHeight > 0 + } + + getBaseCharacterWidth () { + return this.measurements ? this.measurements.baseCharacterWidth : null + } + + getScrollTop () { + return this.measurements ? this.measurements.scrollTop : null + } + + getScrollLeft () { + return this.measurements ? this.measurements.scrollLeft : null + } + + getTileStartRow (row) { + return row - (row % ROWS_PER_TILE) + } + + getFirstVisibleRow () { + const {scrollTop, lineHeight} = this.measurements + return Math.floor(scrollTop / lineHeight) + } + + getLastVisibleRow () { + const {scrollTop, scrollerHeight, lineHeight} = this.measurements + return this.getFirstVisibleRow() + Math.ceil(scrollerHeight / lineHeight) + } + + topPixelPositionForRow (row) { + return row * this.measurements.lineHeight + } + + getScrollHeight () { + return this.getModel().getApproximateScreenLineCount() * this.measurements.lineHeight + } +} + +class LineComponent { + constructor ({displayLayer, screenLine}) { + const {lineText, tagCodes} = screenLine + this.element = document.createElement('div') + this.element.classList.add('line') + + const textNodes = [] + let startIndex = 0 + let openScopeNode = this.element + for (let i = 0; i < tagCodes.length; i++) { + const tagCode = tagCodes[i] + if (tagCode !== 0) { + if (displayLayer.isCloseTagCode(tagCode)) { + openScopeNode = openScopeNode.parentElement + } else if (displayLayer.isOpenTagCode(tagCode)) { + const scope = displayLayer.tagForCode(tagCode) + const newScopeNode = document.createElement('span') + newScopeNode.className = classNameForScopeName(scope) + openScopeNode.appendChild(newScopeNode) + openScopeNode = newScopeNode + } else { + const textNode = document.createTextNode(lineText.substr(startIndex, tagCode)) + startIndex += tagCode + openScopeNode.appendChild(textNode) + textNodes.push(textNode) + } + } + } + + if (startIndex === 0) { + const textNode = document.createTextNode(' ') + this.element.appendChild(textNode) + textNodes.push(textNode) + } + + if (lineText.endsWith(displayLayer.foldCharacter)) { + // Insert a zero-width non-breaking whitespace, so that LinesYardstick can + // take the fold-marker::after pseudo-element into account during + // measurements when such marker is the last character on the line. + const textNode = document.createTextNode(ZERO_WIDTH_NBSP) + this.element.appendChild(textNode) + textNodes.push(textNode) + } + + // this.textNodesByLineId[id] = textNodes + } + + update () {} +} + +const classNamesByScopeName = new Map() +function classNameForScopeName (scopeName) { + let classString = classNamesByScopeName.get(scopeName) + if (classString == null) { + classString = scopeName.replace(/\.+/g, ' ') + classNamesByScopeName.set(scopeName, classString) + } + return classString +} diff --git a/src/text-editor-element.coffee b/src/text-editor-element-old.coffee similarity index 100% rename from src/text-editor-element.coffee rename to src/text-editor-element-old.coffee diff --git a/src/text-editor-element.js b/src/text-editor-element.js new file mode 100644 index 00000000000..9de974d67e8 --- /dev/null +++ b/src/text-editor-element.js @@ -0,0 +1,57 @@ +const {Emitter} = require('atom') +const TextEditorComponent = require('./text-editor-component') + +class TextEditorElement extends HTMLElement { + initialize (component) { + this.component = component + this.emitter = new Emitter() + return this + } + + attachedCallback () { + this.getComponent().didAttach() + this.emitter.emit('did-attach') + } + + getModel () { + return this.getComponent().getModel() + } + + setModel (model) { + this.getComponent().setModel(model) + } + + onDidAttach (callback) { + return this.emitter.on('did-attach', callback) + } + + onDidChangeScrollLeft (callback) { + return this.emitter.on('did-change-scroll-left', callback) + } + + onDidChangeScrollTop (callback) { + return this.emitter.on('did-change-scrol-top', callback) + } + + getDefaultCharacterWidth () { + return this.getComponent().getBaseCharacterWidth() + } + + getScrollTop () { + return this.getComponent().getScrollTop() + } + + getScrollLeft () { + return this.getComponent().getScrollLeft() + } + + getComponent () { + if (!this.component) this.component = new TextEditorComponent({element: this}) + return this.component + } +} + +module.exports = +document.registerElement('atom-text-editor', { + prototype: TextEditorElement.prototype +}) diff --git a/src/text-editor.coffee b/src/text-editor.coffee index f32109cbb2f..812c2274988 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -12,7 +12,7 @@ Model = require './model' Selection = require './selection' TextMateScopeSelector = require('first-mate').ScopeSelector GutterContainer = require './gutter-container' -TextEditorElement = require './text-editor-element' +TextEditorComponent = require './text-editor-component' {isDoubleWidthCharacter, isHalfWidthCharacter, isKoreanCharacter, isWrapBoundary} = require './text-utils' ZERO_WIDTH_NBSP = '\ufeff' @@ -3543,7 +3543,8 @@ class TextEditor extends Model # Get the Element for the editor. getElement: -> - @editorElement ?= new TextEditorElement().initialize(this, atom) + @component ?= new TextEditorComponent({model: this}) + @component.element # Essential: Retrieves the greyed out placeholder of a mini editor. # diff --git a/static/atom.less b/static/atom.less index caa1e1c6bd0..78bb8f2eab7 100644 --- a/static/atom.less +++ b/static/atom.less @@ -21,6 +21,7 @@ @import "docks"; @import "panes"; @import "syntax"; +@import "text-editor"; @import "text-editor-light"; @import "title-bar"; @import "workspace-view"; diff --git a/static/text-editor-light.less b/static/text-editor-light.less index 8683a402cf2..f8d87270cc7 100644 --- a/static/text-editor-light.less +++ b/static/text-editor-light.less @@ -6,61 +6,61 @@ atom-text-editor { display: flex; font-family: Menlo, Consolas, 'DejaVu Sans Mono', monospace; - .editor--private, .editor-contents--private { - height: 100%; - width: 100%; - background-color: inherit; - } - - .editor-contents--private { - width: 100%; - cursor: text; - display: flex; - -webkit-user-select: none; - position: relative; - } - - .gutter-container { - background-color: inherit; - } - - .gutter { - overflow: hidden; - z-index: 0; - text-align: right; - cursor: default; - min-width: 1em; - box-sizing: border-box; - background-color: inherit; - } - - .line-numbers { - position: relative; - background-color: inherit; - } - - .line-number { - position: relative; - white-space: nowrap; - padding-left: .5em; - opacity: 0.6; - - &.cursor-line { - opacity: 1; - } - - .icon-right { - .octicon(chevron-down, 0.8em); - display: inline-block; - visibility: hidden; - opacity: .6; - padding: 0 .4em; - - &::before { - text-align: center; - } - } - } + // .editor--private, .editor-contents--private { + // height: 100%; + // width: 100%; + // background-color: inherit; + // } + // + // .editor-contents--private { + // width: 100%; + // cursor: text; + // display: flex; + // -webkit-user-select: none; + // position: relative; + // } + // + // .gutter-container { + // background-color: inherit; + // } + + // .gutter { + // overflow: hidden; + // z-index: 0; + // text-align: right; + // cursor: default; + // min-width: 1em; + // box-sizing: border-box; + // background-color: inherit; + // } + // + // .line-numbers { + // position: relative; + // background-color: inherit; + // } + + // .line-number { + // position: relative; + // // white-space: nowrap; + // padding-left: .5em; + // opacity: 0.6; + // + // &.cursor-line { + // opacity: 1; + // } + // + // .icon-right { + // .octicon(chevron-down, 0.8em); + // display: inline-block; + // visibility: hidden; + // opacity: .6; + // padding: 0 .4em; + // + // &::before { + // text-align: center; + // } + // } + // } .gutter:hover { .line-number.foldable .icon-right { @@ -85,16 +85,14 @@ atom-text-editor { } } - .scroll-view { - position: relative; - z-index: 0; - - overflow: hidden; - flex: 1; - min-width: 0; - min-height: 0; - background-color: inherit; - } + // .scroll-view { + // position: relative; + // z-index: 0; + // overflow: hidden; + // flex: 1; + // min-width: 0; + // min-height: 0; + // } .highlight { background: none; @@ -107,12 +105,12 @@ atom-text-editor { z-index: -1; } - .lines { - min-width: 100%; - position: relative; - z-index: 1; - background-color: inherit; - } + // .lines { + // min-width: 100%; + // position: relative; + // z-index: 1; + // background-color: inherit; + // } .line { white-space: pre; diff --git a/static/text-editor.less b/static/text-editor.less new file mode 100644 index 00000000000..3ab02652784 --- /dev/null +++ b/static/text-editor.less @@ -0,0 +1,50 @@ +atom-text-editor { + position: relative; + + .scroll-view { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + overflow: auto; + background-color: inherit; + } + + .gutter-container { + float: left; + width: min-content; + background-color: inherit; + } + + .line-numbers { + width: min-content; + background-color: inherit; + } + + .line-number { + width: min-content; + padding-left: .5em; + white-space: nowrap; + opacity: 0.6; + + .icon-right { + .octicon(chevron-down, 0.8em); + display: inline-block; + visibility: hidden; + opacity: .6; + padding: 0 .4em; + + &::before { + text-align: center; + } + } + } + + .lines { + background-color: inherit; + float: left; + // width: min-content; + // height: min-content; + } +} From f94144ff4b393d0967791fbdf5d03d19758d801b Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 22 Feb 2017 10:34:58 -0700 Subject: [PATCH 046/403] WIP --- src/text-editor-component.js | 59 ++++++++++++++++++++++++------------ static/text-editor.less | 5 +-- 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 7cd9cd2b388..6cb8b9fac53 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -36,10 +36,23 @@ class TextEditorComponent { } render () { - return $('atom-text-editor', null, + let style + if (!this.getModel().getAutoHeight() && !this.getModel().getAutoWidth()) { + style = {contain: 'strict'} + } + + return $('atom-text-editor', {style}, $.div({ref: 'scroller', onScroll: this.didScroll, className: 'scroll-view'}, - this.renderGutterContainer(), - this.renderLines() + // $.div({ + // style: { + // width: 'max-content', + // height: 'max-content', + // backgroundColor: 'inherit' + // } + // }, + // this.renderGutterContainer(), + this.renderLines() + // ) ) ) } @@ -56,8 +69,6 @@ class TextEditorComponent { const firstTileStartRow = this.getTileStartRow(this.getFirstVisibleRow()) const lastTileStartRow = this.getTileStartRow(this.getLastVisibleRow()) - console.log({firstTileStartRow, lastTileStartRow}); - let tileNodes = [] let currentTileStaticTop = 0 @@ -95,9 +106,11 @@ class TextEditorComponent { tileNodes.push($.div({ style: { height: tileHeight + 'px', - width: 'min-content', - transform: `translateY(${yTranslation}px)`, + width: 'max-content', + willChange: 'transform', + transform: `translate3d(0, ${yTranslation}px, 0)`, backgroundColor: 'inherit', + overflow: 'hidden' } }, lineNumberNodes)) @@ -121,18 +134,13 @@ class TextEditorComponent { if (!this.measurements) return [] const firstTileStartRow = this.getTileStartRow(this.getFirstVisibleRow()) - const lastTileStartRow = this.getTileStartRow(this.getLastVisibleRow()) - const visibleTileCount = lastTileStartRow - firstTileStartRow + 1 + const visibleTileCount = Math.floor((this.getLastVisibleRow() - this.getFirstVisibleRow()) / ROWS_PER_TILE) + 2 + const lastTileStartRow = firstTileStartRow + ((visibleTileCount - 1) * ROWS_PER_TILE) + const displayLayer = this.getModel().displayLayer const screenLines = displayLayer.getScreenLines(firstTileStartRow, lastTileStartRow + ROWS_PER_TILE) - console.log({ - firstVisible: this.getFirstVisibleRow(), - lastVisible: this.getLastVisibleRow(), - firstTileStartRow, lastTileStartRow - }); - - let tileNodes = [] + let tileNodes = new Array(visibleTileCount) for (let tileStartRow = firstTileStartRow; tileStartRow <= lastTileStartRow; tileStartRow += ROWS_PER_TILE) { const tileEndRow = tileStartRow + ROWS_PER_TILE const lineNodes = [] @@ -143,17 +151,21 @@ class TextEditorComponent { } const tileHeight = ROWS_PER_TILE * this.measurements.lineHeight + const tileIndex = (tileStartRow / ROWS_PER_TILE) % visibleTileCount - tileNodes.push($.div({ - key: (tileStartRow / ROWS_PER_TILE) % visibleTileCount, + tileNodes[tileIndex] = $.div({ + key: tileIndex, + dataset: {key: tileIndex}, style: { + contain: 'strict', position: 'absolute', height: tileHeight + 'px', width: this.measurements.scrollWidth + 'px', + willChange: 'transform', transform: `translateY(${this.topPixelPositionForRow(tileStartRow)}px)`, backgroundColor: 'inherit' } - }, lineNodes)) + }, lineNodes) } return tileNodes @@ -164,6 +176,8 @@ class TextEditorComponent { const {intersectionRect} = entries[entries.length - 1] if (intersectionRect.width > 0 || intersectionRect.height > 0) { this.didShow() + } else { + this.didHide() } }) this.intersectionObserver.observe(this.element) @@ -171,10 +185,15 @@ class TextEditorComponent { } didShow () { + this.getModel().setVisible(true) if (!this.measurements) this.performInitialMeasurements() etch.updateSync(this) } + didHide () { + this.getModel().setVisible(false) + } + didScroll () { this.measureScrollPosition() this.updateSync() @@ -209,7 +228,7 @@ class TextEditorComponent { measureLongestLineWidth () { const displayLayer = this.getModel().displayLayer - const rightmostPosition = displayLayer.getApproximateRightmostScreenPosition() + const rightmostPosition = displayLayer.getRightmostScreenPosition() this.measurements.scrollWidth = rightmostPosition.column * this.measurements.baseCharacterWidth } diff --git a/static/text-editor.less b/static/text-editor.less index 3ab02652784..586a727f501 100644 --- a/static/text-editor.less +++ b/static/text-editor.less @@ -42,9 +42,10 @@ atom-text-editor { } .lines { + contain: strict; background-color: inherit; float: left; - // width: min-content; - // height: min-content; + will-change: transform; + overflow: hidden; } } From aed4d8876f4b72d5328b16575de34b4be7951809 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 23 Feb 2017 10:51:25 -0700 Subject: [PATCH 047/403] Use contain: strict on line number gutter and its tiles This improves layout time of scrolling by limiting the extent of gutter re-layouts. Signed-off-by: Antonio Scandurra --- src/text-editor-component.js | 137 +++++++++++++++++++++-------------- static/text-editor.less | 3 +- 2 files changed, 84 insertions(+), 56 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 6cb8b9fac53..1001e2114bf 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -43,81 +43,103 @@ class TextEditorComponent { return $('atom-text-editor', {style}, $.div({ref: 'scroller', onScroll: this.didScroll, className: 'scroll-view'}, - // $.div({ - // style: { - // width: 'max-content', - // height: 'max-content', - // backgroundColor: 'inherit' - // } - // }, - // this.renderGutterContainer(), + $.div({ + style: { + isolate: 'content', + width: 'max-content', + height: 'max-content', + backgroundColor: 'inherit' + } + }, + this.renderGutterContainer(), this.renderLines() - // ) + ) ) ) } renderGutterContainer () { return $.div({className: 'gutter-container'}, - this.measurements ? this.renderLineNumberGutter() : [] + this.renderLineNumberGutter() ) } renderLineNumberGutter () { const maxLineNumberDigits = Math.max(2, this.getModel().getLineCount().toString().length) - const firstTileStartRow = this.getTileStartRow(this.getFirstVisibleRow()) - const lastTileStartRow = this.getTileStartRow(this.getLastVisibleRow()) - - let tileNodes = [] - - let currentTileStaticTop = 0 - let previousBufferRow = (firstTileStartRow > 0) ? this.getModel().bufferRowForScreenRow(firstTileStartRow - 1) : -1 - for (let tileStartRow = firstTileStartRow; tileStartRow <= lastTileStartRow; tileStartRow += ROWS_PER_TILE) { - const currentTileEndRow = tileStartRow + ROWS_PER_TILE - const lineNumberNodes = [] - - for (let row = tileStartRow; row < currentTileEndRow; row++) { - const bufferRow = this.getModel().bufferRowForScreenRow(row) - const foldable = this.getModel().isFoldableAtBufferRow(bufferRow) - const softWrapped = (bufferRow === previousBufferRow) - - let className = 'line-number' - let lineNumber - if (softWrapped) { - lineNumber = '•' - } else { - if (foldable) className += ' foldable' - lineNumber = (bufferRow + 1).toString() - } - lineNumber = '\u00a0'.repeat(maxLineNumberDigits - lineNumber.length) + lineNumber - - lineNumberNodes.push($.div({className}, - lineNumber, - $.div({className: 'icon-right'}) - )) + let props = { + ref: 'lineNumberGutter', + className: 'gutter line-numbers', + 'gutter-name': 'line-number' + } + let children - previousBufferRow = bufferRow + if (this.measurements) { + props.style = { + height: this.getScrollHeight() + 'px', + width: this.measurements.lineNumberGutterWidth + 'px', + contain: 'strict' } - const tileHeight = ROWS_PER_TILE * this.measurements.lineHeight - const yTranslation = this.topPixelPositionForRow(tileStartRow) - currentTileStaticTop - - tileNodes.push($.div({ - style: { - height: tileHeight + 'px', - width: 'max-content', - willChange: 'transform', - transform: `translate3d(0, ${yTranslation}px, 0)`, - backgroundColor: 'inherit', - overflow: 'hidden' + const firstTileStartRow = this.getTileStartRow(this.getFirstVisibleRow()) + const visibleTileCount = Math.floor((this.getLastVisibleRow() - this.getFirstVisibleRow()) / ROWS_PER_TILE) + 2 + const lastTileStartRow = firstTileStartRow + ((visibleTileCount - 1) * ROWS_PER_TILE) + + children = new Array(visibleTileCount) + + let previousBufferRow = (firstTileStartRow > 0) ? this.getModel().bufferRowForScreenRow(firstTileStartRow - 1) : -1 + for (let tileStartRow = firstTileStartRow; tileStartRow <= lastTileStartRow; tileStartRow += ROWS_PER_TILE) { + const currentTileEndRow = tileStartRow + ROWS_PER_TILE + const lineNumberNodes = [] + + for (let row = tileStartRow; row < currentTileEndRow; row++) { + const bufferRow = this.getModel().bufferRowForScreenRow(row) + const foldable = this.getModel().isFoldableAtBufferRow(bufferRow) + const softWrapped = (bufferRow === previousBufferRow) + + let className = 'line-number' + let lineNumber + if (softWrapped) { + lineNumber = '•' + } else { + if (foldable) className += ' foldable' + lineNumber = (bufferRow + 1).toString() + } + lineNumber = '\u00a0'.repeat(maxLineNumberDigits - lineNumber.length) + lineNumber + + lineNumberNodes.push($.div({className}, + lineNumber, + $.div({className: 'icon-right'}) + )) + + previousBufferRow = bufferRow } - }, lineNumberNodes)) - currentTileStaticTop += tileHeight + const tileIndex = (tileStartRow / ROWS_PER_TILE) % visibleTileCount + const tileHeight = ROWS_PER_TILE * this.measurements.lineHeight + const yTranslation = this.topPixelPositionForRow(tileStartRow) - (tileIndex * tileHeight) + + children[tileIndex] = $.div({ + style: { + // position: 'absolute', + height: tileHeight + 'px', + width: this.measurements.lineNumberGutterWidth + 'px', + willChange: 'transform', + transform: `translateY(${yTranslation}px)`, + backgroundColor: 'inherit', + contain: 'strict', + overflow: 'hidden' + } + }, lineNumberNodes) + } + } else { + children = $.div({className: 'line-number'}, + '0'.repeat(maxLineNumberDigits), + $.div({className: 'icon-right'}) + ) } - return $.div({className: 'gutter line-numbers', 'gutter-name': 'line-number'}, tileNodes) + return $.div(props, children) } renderLines () { @@ -205,6 +227,7 @@ class TextEditorComponent { this.measureScrollPosition() this.measureCharacterDimensions() this.measureLongestLineWidth() + this.measureGutterDimensions() } measureEditorDimensions () { @@ -232,6 +255,10 @@ class TextEditorComponent { this.measurements.scrollWidth = rightmostPosition.column * this.measurements.baseCharacterWidth } + measureGutterDimensions () { + this.measurements.lineNumberGutterWidth = this.refs.lineNumberGutter.offsetWidth + } + getModel () { if (!this.props.model) { const TextEditor = require('./text-editor') diff --git a/static/text-editor.less b/static/text-editor.less index 586a727f501..a954434490a 100644 --- a/static/text-editor.less +++ b/static/text-editor.less @@ -18,8 +18,9 @@ atom-text-editor { } .line-numbers { - width: min-content; + width: max-content; background-color: inherit; + contain: content; } .line-number { From b38fafc83a7bf54dc634c71c71ff7689e173aa42 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 23 Feb 2017 13:04:14 -0700 Subject: [PATCH 048/403] Absolutely position line number tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static positioning doesn’t seem to improve layout performance --- src/text-editor-component.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 1001e2114bf..58749485b5e 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -76,9 +76,10 @@ class TextEditorComponent { if (this.measurements) { props.style = { + contain: 'strict', + overflow: 'hidden', height: this.getScrollHeight() + 'px', - width: this.measurements.lineNumberGutterWidth + 'px', - contain: 'strict' + width: this.measurements.lineNumberGutterWidth + 'px' } const firstTileStartRow = this.getTileStartRow(this.getFirstVisibleRow()) @@ -117,18 +118,17 @@ class TextEditorComponent { const tileIndex = (tileStartRow / ROWS_PER_TILE) % visibleTileCount const tileHeight = ROWS_PER_TILE * this.measurements.lineHeight - const yTranslation = this.topPixelPositionForRow(tileStartRow) - (tileIndex * tileHeight) children[tileIndex] = $.div({ style: { - // position: 'absolute', + contain: 'strict', + overflow: 'hidden', + position: 'absolute', height: tileHeight + 'px', width: this.measurements.lineNumberGutterWidth + 'px', willChange: 'transform', - transform: `translateY(${yTranslation}px)`, - backgroundColor: 'inherit', - contain: 'strict', - overflow: 'hidden' + transform: `translateY(${this.topPixelPositionForRow(tileStartRow)}px)`, + backgroundColor: 'inherit' } }, lineNumberNodes) } From d2d560eac66128430b7a33b225634d80d23abe5e Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 23 Feb 2017 13:11:11 -0700 Subject: [PATCH 049/403] Render character measurement line via virtual DOM Signed-off-by: Max Brunsfeld --- src/text-editor-component.js | 38 ++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 58749485b5e..aa9ad992e11 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -8,14 +8,6 @@ const DOUBLE_WIDTH_CHARACTER = '我' const HALF_WIDTH_CHARACTER = 'ハ' const KOREAN_CHARACTER = '세' -const characterMeasurementSpans = {} -const characterMeasurementLineNode = etch.render($.div({className: 'line'}, - $.span({ref: 'normalWidthCharacterSpan'}, NORMAL_WIDTH_CHARACTER), - $.span({ref: 'doubleWidthCharacterSpan'}, DOUBLE_WIDTH_CHARACTER), - $.span({ref: 'halfWidthCharacterSpan'}, HALF_WIDTH_CHARACTER), - $.span({ref: 'koreanCharacterSpan'}, KOREAN_CHARACTER) -), {refs: characterMeasurementSpans}) - module.exports = class TextEditorComponent { constructor (props) { @@ -143,13 +135,23 @@ class TextEditorComponent { } renderLines () { - const style = (this.measurements) - ? { + let style, children + if (this.measurements) { + style = { width: this.measurements.scrollWidth + 'px', height: this.getScrollHeight() + 'px' - } : null + } + children = this.renderLineTiles() + } else { + children = $.div({ref: 'characterMeasurementLine', className: 'line'}, + $.span({ref: 'normalWidthCharacterSpan'}, NORMAL_WIDTH_CHARACTER), + $.span({ref: 'doubleWidthCharacterSpan'}, DOUBLE_WIDTH_CHARACTER), + $.span({ref: 'halfWidthCharacterSpan'}, HALF_WIDTH_CHARACTER), + $.span({ref: 'koreanCharacterSpan'}, KOREAN_CHARACTER) + ) + } - return $.div({ref: 'lines', className: 'lines', style}, this.renderLineTiles()) + return $.div({ref: 'lines', className: 'lines', style}, children) } renderLineTiles () { @@ -240,13 +242,11 @@ class TextEditorComponent { } measureCharacterDimensions () { - this.refs.lines.appendChild(characterMeasurementLineNode) - this.measurements.lineHeight = characterMeasurementLineNode.getBoundingClientRect().height - this.measurements.baseCharacterWidth = characterMeasurementSpans.normalWidthCharacterSpan.getBoundingClientRect().width - this.measurements.doubleWidthCharacterWidth = characterMeasurementSpans.doubleWidthCharacterSpan.getBoundingClientRect().width - this.measurements.halfWidthCharacterWidth = characterMeasurementSpans.halfWidthCharacterSpan.getBoundingClientRect().width - this.measurements.koreanCharacterWidth = characterMeasurementSpans.koreanCharacterSpan.getBoundingClientRect().widt - this.refs.lines.removeChild(characterMeasurementLineNode) + this.measurements.lineHeight = this.refs.characterMeasurementLine.getBoundingClientRect().height + this.measurements.baseCharacterWidth = this.refs.normalWidthCharacterSpan.getBoundingClientRect().width + this.measurements.doubleWidthCharacterWidth = this.refs.doubleWidthCharacterSpan.getBoundingClientRect().width + this.measurements.halfWidthCharacterWidth = this.refs.halfWidthCharacterSpan.getBoundingClientRect().width + this.measurements.koreanCharacterWidth = this.refs.koreanCharacterSpan.getBoundingClientRect().widt } measureLongestLineWidth () { From 9765d9dbcdda4dbaca7db91be4ab2cc8287ef6cd Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 23 Feb 2017 13:24:59 -0700 Subject: [PATCH 050/403] Translate gutter so it remains visible when scrolling to the right Signed-off-by: Max Brunsfeld --- src/text-editor-component.js | 15 ++++++++++++--- static/text-editor.less | 1 + 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index aa9ad992e11..b9b08b8a8d6 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -51,9 +51,18 @@ class TextEditorComponent { } renderGutterContainer () { - return $.div({className: 'gutter-container'}, - this.renderLineNumberGutter() - ) + const props = {className: 'gutter-container'} + + if (this.measurements) { + props.style = { + position: 'relative', + willChange: 'transform', + transform: `translateX(${this.measurements.scrollLeft}px)`, + zIndex: 1 + } + } + + return $.div(props, this.renderLineNumberGutter()) } renderLineNumberGutter () { diff --git a/static/text-editor.less b/static/text-editor.less index a954434490a..71482f5f53e 100644 --- a/static/text-editor.less +++ b/static/text-editor.less @@ -3,6 +3,7 @@ atom-text-editor { .scroll-view { position: absolute; + contain: strict; top: 0; right: 0; bottom: 0; From b863790390cdd551617bfcfba79e5322e8318244 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 23 Feb 2017 15:25:02 -0700 Subject: [PATCH 051/403] Start on new TextEditorComponent specs; avoid excessive line numbers --- spec/text-editor-component-spec-old.js | 5128 +++++++++++++++++++++++ spec/text-editor-component-spec.js | 5216 +----------------------- src/text-editor-component.js | 87 +- src/text-editor-element.js | 11 +- 4 files changed, 5274 insertions(+), 5168 deletions(-) create mode 100644 spec/text-editor-component-spec-old.js diff --git a/spec/text-editor-component-spec-old.js b/spec/text-editor-component-spec-old.js new file mode 100644 index 00000000000..e145bac9020 --- /dev/null +++ b/spec/text-editor-component-spec-old.js @@ -0,0 +1,5128 @@ +/** @babel */ + +import {it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise} from './async-spec-helpers' +import Grim from 'grim' +import TextEditor from '../src/text-editor' +import TextEditorElement from '../src/text-editor-element' +import _, {extend, flatten, last, toArray} from 'underscore-plus' + +const NBSP = String.fromCharCode(160) +const TILE_SIZE = 3 + +describe('TextEditorComponent', function () { + let charWidth, component, componentNode, contentNode, editor, + horizontalScrollbarNode, lineHeightInPixels, tileHeightInPixels, + verticalScrollbarNode, wrapperNode, animationFrameRequests + + function runAnimationFrames (runFollowupFrames) { + if (runFollowupFrames) { + let fn + while (fn = animationFrameRequests.shift()) fn() + } else { + const requests = animationFrameRequests.slice() + animationFrameRequests = [] + for (let fn of requests) fn() + } + } + + beforeEach(async function () { + animationFrameRequests = [] + spyOn(window, 'requestAnimationFrame').andCallFake(function (fn) { animationFrameRequests.push(fn) }) + jasmine.useMockClock() + + await atom.packages.activatePackage('language-javascript') + editor = await atom.workspace.open('sample.js') + editor.update({autoHeight: true}) + + contentNode = document.querySelector('#jasmine-content') + contentNode.style.width = '1000px' + + wrapperNode = new TextEditorElement() + wrapperNode.tileSize = TILE_SIZE + wrapperNode.initialize(editor, atom) + wrapperNode.setUpdatedSynchronously(false) + jasmine.attachToDOM(wrapperNode) + + component = wrapperNode.component + component.setFontFamily('monospace') + component.setLineHeight(1.3) + component.setFontSize(20) + + lineHeightInPixels = editor.getLineHeightInPixels() + tileHeightInPixels = TILE_SIZE * lineHeightInPixels + charWidth = editor.getDefaultCharWidth() + + componentNode = component.getDomNode() + verticalScrollbarNode = componentNode.querySelector('.vertical-scrollbar') + horizontalScrollbarNode = componentNode.querySelector('.horizontal-scrollbar') + + component.measureDimensions() + runAnimationFrames(true) + }) + + afterEach(function () { + contentNode.style.width = '' + }) + + describe('async updates', function () { + it('handles corrupted state gracefully', function () { + editor.insertNewline() + component.presenter.startRow = -1 + component.presenter.endRow = 9999 + runAnimationFrames() // assert an update does occur + }) + + it('does not update when an animation frame was requested but the component got destroyed before its delivery', function () { + editor.setText('You should not see this update.') + component.destroy() + + runAnimationFrames() + + expect(component.lineNodeForScreenRow(0).textContent).not.toBe('You should not see this update.') + }) + }) + + describe('line rendering', function () { + function expectTileContainsRow (tileNode, screenRow, {top}) { + let lineNode = tileNode.querySelector('[data-screen-row="' + screenRow + '"]') + let text = editor.lineTextForScreenRow(screenRow) + expect(lineNode.offsetTop).toBe(top) + if (text === '') { + expect(lineNode.textContent).toBe(' ') + } else { + expect(lineNode.textContent).toBe(text) + } + } + + it('gives the lines container the same height as the wrapper node', function () { + let linesNode = componentNode.querySelector('.lines') + wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + expect(linesNode.getBoundingClientRect().height).toBe(6.5 * lineHeightInPixels) + wrapperNode.style.height = 3.5 * lineHeightInPixels + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + + runAnimationFrames() + + expect(linesNode.getBoundingClientRect().height).toBe(3.5 * lineHeightInPixels) + }) + + it('renders higher tiles in front of lower ones', function () { + wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + + runAnimationFrames() + + let tilesNodes = component.tileNodesForLines() + expect(tilesNodes[0].style.zIndex).toBe('2') + expect(tilesNodes[1].style.zIndex).toBe('1') + expect(tilesNodes[2].style.zIndex).toBe('0') + verticalScrollbarNode.scrollTop = 1 * lineHeightInPixels + verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) + runAnimationFrames(true) + + tilesNodes = component.tileNodesForLines() + expect(tilesNodes[0].style.zIndex).toBe('3') + expect(tilesNodes[1].style.zIndex).toBe('2') + expect(tilesNodes[2].style.zIndex).toBe('1') + expect(tilesNodes[3].style.zIndex).toBe('0') + }) + + it('renders the currently-visible lines in a tiled fashion', function () { + wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + + runAnimationFrames() + + let tilesNodes = component.tileNodesForLines() + expect(tilesNodes.length).toBe(3) + + expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, 0px, 0px)') + expect(tilesNodes[0].querySelectorAll('.line').length).toBe(TILE_SIZE) + expectTileContainsRow(tilesNodes[0], 0, { + top: 0 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[0], 1, { + top: 1 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[0], 2, { + top: 2 * lineHeightInPixels + }) + + expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels) + 'px, 0px)') + expect(tilesNodes[1].querySelectorAll('.line').length).toBe(TILE_SIZE) + expectTileContainsRow(tilesNodes[1], 3, { + top: 0 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[1], 4, { + top: 1 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[1], 5, { + top: 2 * lineHeightInPixels + }) + + expect(tilesNodes[2].style['-webkit-transform']).toBe('translate3d(0px, ' + (2 * tileHeightInPixels) + 'px, 0px)') + expect(tilesNodes[2].querySelectorAll('.line').length).toBe(TILE_SIZE) + expectTileContainsRow(tilesNodes[2], 6, { + top: 0 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[2], 7, { + top: 1 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[2], 8, { + top: 2 * lineHeightInPixels + }) + + expect(component.lineNodeForScreenRow(9)).toBeUndefined() + + verticalScrollbarNode.scrollTop = TILE_SIZE * lineHeightInPixels + 5 + verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) + runAnimationFrames(true) + + tilesNodes = component.tileNodesForLines() + expect(component.lineNodeForScreenRow(2)).toBeUndefined() + expect(tilesNodes.length).toBe(3) + + expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, ' + (0 * tileHeightInPixels - 5) + 'px, 0px)') + expect(tilesNodes[0].querySelectorAll('.line').length).toBe(TILE_SIZE) + expectTileContainsRow(tilesNodes[0], 3, { + top: 0 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[0], 4, { + top: 1 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[0], 5, { + top: 2 * lineHeightInPixels + }) + + expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels - 5) + 'px, 0px)') + expect(tilesNodes[1].querySelectorAll('.line').length).toBe(TILE_SIZE) + expectTileContainsRow(tilesNodes[1], 6, { + top: 0 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[1], 7, { + top: 1 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[1], 8, { + top: 2 * lineHeightInPixels + }) + + expect(tilesNodes[2].style['-webkit-transform']).toBe('translate3d(0px, ' + (2 * tileHeightInPixels - 5) + 'px, 0px)') + expect(tilesNodes[2].querySelectorAll('.line').length).toBe(TILE_SIZE) + expectTileContainsRow(tilesNodes[2], 9, { + top: 0 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[2], 10, { + top: 1 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[2], 11, { + top: 2 * lineHeightInPixels + }) + }) + + it('updates the top position of subsequent tiles when lines are inserted or removed', function () { + wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + editor.getBuffer().deleteRows(0, 1) + + runAnimationFrames() + + let tilesNodes = component.tileNodesForLines() + expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, 0px, 0px)') + expectTileContainsRow(tilesNodes[0], 0, { + top: 0 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[0], 1, { + top: 1 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[0], 2, { + top: 2 * lineHeightInPixels + }) + + expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels) + 'px, 0px)') + expectTileContainsRow(tilesNodes[1], 3, { + top: 0 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[1], 4, { + top: 1 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[1], 5, { + top: 2 * lineHeightInPixels + }) + + editor.getBuffer().insert([0, 0], '\n\n') + + runAnimationFrames() + + tilesNodes = component.tileNodesForLines() + expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, 0px, 0px)') + expectTileContainsRow(tilesNodes[0], 0, { + top: 0 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[0], 1, { + top: 1 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[0], 2, { + top: 2 * lineHeightInPixels + }) + + expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels) + 'px, 0px)') + expectTileContainsRow(tilesNodes[1], 3, { + top: 0 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[1], 4, { + top: 1 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[1], 5, { + top: 2 * lineHeightInPixels + }) + + expect(tilesNodes[2].style['-webkit-transform']).toBe('translate3d(0px, ' + (2 * tileHeightInPixels) + 'px, 0px)') + expectTileContainsRow(tilesNodes[2], 6, { + top: 0 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[2], 7, { + top: 1 * lineHeightInPixels + }) + expectTileContainsRow(tilesNodes[2], 8, { + top: 2 * lineHeightInPixels + }) + }) + + it('updates the lines when lines are inserted or removed above the rendered row range', function () { + wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + verticalScrollbarNode.scrollTop = 5 * lineHeightInPixels + verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) + runAnimationFrames(true) + + let buffer = editor.getBuffer() + buffer.insert([0, 0], '\n\n') + + runAnimationFrames() + + expect(component.lineNodeForScreenRow(3).textContent).toBe(editor.lineTextForScreenRow(3)) + buffer.delete([[0, 0], [3, 0]]) + + runAnimationFrames() + + expect(component.lineNodeForScreenRow(3).textContent).toBe(editor.lineTextForScreenRow(3)) + }) + + it('updates the top position of lines when the line height changes', function () { + let initialLineHeightInPixels = editor.getLineHeightInPixels() + + component.setLineHeight(2) + + runAnimationFrames() + + let newLineHeightInPixels = editor.getLineHeightInPixels() + expect(newLineHeightInPixels).not.toBe(initialLineHeightInPixels) + expect(component.lineNodeForScreenRow(1).offsetTop).toBe(1 * newLineHeightInPixels) + }) + + it('updates the top position of lines when the font size changes', function () { + let initialLineHeightInPixels = editor.getLineHeightInPixels() + component.setFontSize(10) + + runAnimationFrames() + + let newLineHeightInPixels = editor.getLineHeightInPixels() + expect(newLineHeightInPixels).not.toBe(initialLineHeightInPixels) + expect(component.lineNodeForScreenRow(1).offsetTop).toBe(1 * newLineHeightInPixels) + }) + + it('renders the .lines div at the full height of the editor if there are not enough lines to scroll vertically', function () { + editor.setText('') + wrapperNode.style.height = '300px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + let linesNode = componentNode.querySelector('.lines') + expect(linesNode.offsetHeight).toBe(300) + }) + + it('assigns the width of each line so it extends across the full width of the editor', function () { + let gutterWidth = componentNode.querySelector('.gutter').offsetWidth + let scrollViewNode = componentNode.querySelector('.scroll-view') + let lineNodes = Array.from(componentNode.querySelectorAll('.line')) + + componentNode.style.width = gutterWidth + (30 * charWidth) + 'px' + component.measureDimensions() + + runAnimationFrames() + + expect(wrapperNode.getScrollWidth()).toBeGreaterThan(scrollViewNode.offsetWidth) + let editorFullWidth = wrapperNode.getScrollWidth() + wrapperNode.getVerticalScrollbarWidth() + for (let lineNode of lineNodes) { + expect(lineNode.getBoundingClientRect().width).toBe(editorFullWidth) + } + + componentNode.style.width = gutterWidth + wrapperNode.getScrollWidth() + 100 + 'px' + component.measureDimensions() + + runAnimationFrames() + + let scrollViewWidth = scrollViewNode.offsetWidth + for (let lineNode of lineNodes) { + expect(lineNode.getBoundingClientRect().width).toBe(scrollViewWidth) + } + }) + + it('renders a placeholder space on empty lines when no line-ending character is defined', function () { + editor.update({showInvisibles: false}) + expect(component.lineNodeForScreenRow(10).textContent).toBe(' ') + }) + + it('gives the lines and tiles divs the same background color as the editor to improve GPU performance', async function () { + let linesNode = componentNode.querySelector('.lines') + let backgroundColor = getComputedStyle(wrapperNode).backgroundColor + + expect(getComputedStyle(linesNode).backgroundColor).toBe(backgroundColor) + for (let tileNode of component.tileNodesForLines()) { + expect(getComputedStyle(tileNode).backgroundColor).toBe(backgroundColor) + } + + wrapperNode.style.backgroundColor = 'rgb(255, 0, 0)' + expect(getComputedStyle(linesNode).backgroundColor).toBe('rgb(255, 0, 0)') + for (let tileNode of component.tileNodesForLines()) { + expect(getComputedStyle(tileNode).backgroundColor).toBe('rgb(255, 0, 0)') + } + }) + + it('applies .leading-whitespace for lines with leading spaces and/or tabs', function () { + editor.setText(' a') + + runAnimationFrames() + + let leafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) + expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(true) + expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(false) + + editor.setText('\ta') + runAnimationFrames() + + leafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) + expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(true) + expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(false) + }) + + it('applies .trailing-whitespace for lines with trailing spaces and/or tabs', function () { + editor.setText(' ') + runAnimationFrames() + + let leafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) + expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(true) + expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(false) + + editor.setText('\t') + runAnimationFrames() + + leafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) + expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(true) + expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(false) + editor.setText('a ') + runAnimationFrames() + + leafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) + expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(true) + expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(false) + editor.setText('a\t') + runAnimationFrames() + + leafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) + expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(true) + expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(false) + }) + + it('keeps rebuilding lines when continuous reflow is on', function () { + wrapperNode.setContinuousReflow(true) + let oldLineNode = componentNode.querySelectorAll('.line')[1] + + while (true) { + advanceClock(component.presenter.minimumReflowInterval) + runAnimationFrames() + if (componentNode.querySelectorAll('.line')[1] !== oldLineNode) break + } + }) + + describe('when showInvisibles is enabled', function () { + const invisibles = { + eol: 'E', + space: 'S', + tab: 'T', + cr: 'C' + } + + beforeEach(function () { + editor.update({ + showInvisibles: true, + invisibles: invisibles + }) + runAnimationFrames() + }) + + it('re-renders the lines when the showInvisibles config option changes', function () { + editor.setText(' a line with tabs\tand spaces \n') + runAnimationFrames() + + expect(component.lineNodeForScreenRow(0).textContent).toBe('' + invisibles.space + 'a line with tabs' + invisibles.tab + 'and spaces' + invisibles.space + invisibles.eol) + + editor.update({showInvisibles: false}) + runAnimationFrames() + + expect(component.lineNodeForScreenRow(0).textContent).toBe(' a line with tabs and spaces ') + + editor.update({showInvisibles: true}) + runAnimationFrames() + + expect(component.lineNodeForScreenRow(0).textContent).toBe('' + invisibles.space + 'a line with tabs' + invisibles.tab + 'and spaces' + invisibles.space + invisibles.eol) + }) + + it('displays leading/trailing spaces, tabs, and newlines as visible characters', function () { + editor.setText(' a line with tabs\tand spaces \n') + + runAnimationFrames() + + expect(component.lineNodeForScreenRow(0).textContent).toBe('' + invisibles.space + 'a line with tabs' + invisibles.tab + 'and spaces' + invisibles.space + invisibles.eol) + + let leafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) + expect(leafNodes[0].classList.contains('invisible-character')).toBe(true) + expect(leafNodes[leafNodes.length - 1].classList.contains('invisible-character')).toBe(true) + }) + + it('displays newlines as their own token outside of the other tokens\' scopeDescriptor', function () { + editor.setText('let\n') + runAnimationFrames() + expect(component.lineNodeForScreenRow(0).innerHTML).toBe('let' + invisibles.eol + '') + }) + + it('displays trailing carriage returns using a visible, non-empty value', function () { + editor.setText('a line that ends with a carriage return\r\n') + runAnimationFrames() + expect(component.lineNodeForScreenRow(0).textContent).toBe('a line that ends with a carriage return' + invisibles.cr + invisibles.eol) + }) + + it('renders invisible line-ending characters on empty lines', function () { + expect(component.lineNodeForScreenRow(10).textContent).toBe(invisibles.eol) + }) + + it('renders a placeholder space on empty lines when the line-ending character is an empty string', function () { + editor.update({invisibles: {eol: ''}}) + runAnimationFrames() + expect(component.lineNodeForScreenRow(10).textContent).toBe(' ') + }) + + it('renders an placeholder space on empty lines when the line-ending character is false', function () { + editor.update({invisibles: {eol: false}}) + runAnimationFrames() + expect(component.lineNodeForScreenRow(10).textContent).toBe(' ') + }) + + it('interleaves invisible line-ending characters with indent guides on empty lines', function () { + editor.update({showIndentGuide: true}) + + runAnimationFrames() + + editor.setTabLength(2) + editor.setTextInBufferRange([[10, 0], [11, 0]], '\r\n', { + normalizeLineEndings: false + }) + runAnimationFrames() + expect(component.lineNodeForScreenRow(10).innerHTML).toBe('CE') + + editor.setTabLength(3) + runAnimationFrames() + expect(component.lineNodeForScreenRow(10).innerHTML).toBe('CE') + + editor.setTabLength(1) + runAnimationFrames() + expect(component.lineNodeForScreenRow(10).innerHTML).toBe('CE') + + editor.setTextInBufferRange([[9, 0], [9, Infinity]], ' ') + editor.setTextInBufferRange([[11, 0], [11, Infinity]], ' ') + runAnimationFrames() + expect(component.lineNodeForScreenRow(10).innerHTML).toBe('CE') + }) + + describe('when soft wrapping is enabled', function () { + beforeEach(function () { + editor.setText('a line that wraps \n') + editor.setSoftWrapped(true) + runAnimationFrames() + + componentNode.style.width = 17 * charWidth + wrapperNode.getVerticalScrollbarWidth() + 'px' + component.measureDimensions() + runAnimationFrames() + }) + + it('does not show end of line invisibles at the end of wrapped lines', function () { + expect(component.lineNodeForScreenRow(0).textContent).toBe('a line ') + expect(component.lineNodeForScreenRow(1).textContent).toBe('that wraps' + invisibles.space + invisibles.eol) + }) + }) + }) + + describe('when indent guides are enabled', function () { + beforeEach(function () { + editor.update({showIndentGuide: true}) + runAnimationFrames() + }) + + it('adds an "indent-guide" class to spans comprising the leading whitespace', function () { + let 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) + + let line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2)) + expect(line2LeafNodes[0].textContent).toBe(' ') + expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe(true) + expect(line2LeafNodes[1].textContent).toBe(' ') + expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe(true) + expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe(false) + }) + + it('renders leading whitespace spans with the "indent-guide" class for empty lines', function () { + editor.getBuffer().insert([1, Infinity], '\n') + runAnimationFrames() + + let line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2)) + expect(line2LeafNodes.length).toBe(2) + expect(line2LeafNodes[0].textContent).toBe(' ') + expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe(true) + expect(line2LeafNodes[1].textContent).toBe(' ') + expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe(true) + }) + + it('renders indent guides correctly on lines containing only whitespace', function () { + editor.getBuffer().insert([1, Infinity], '\n ') + runAnimationFrames() + + let line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2)) + expect(line2LeafNodes.length).toBe(3) + expect(line2LeafNodes[0].textContent).toBe(' ') + expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe(true) + expect(line2LeafNodes[1].textContent).toBe(' ') + expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe(true) + expect(line2LeafNodes[2].textContent).toBe(' ') + expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe(true) + }) + + it('renders indent guides correctly on lines containing only whitespace when invisibles are enabled', function () { + editor.update({ + showInvisibles: true, + invisibles: { + space: '-', + eol: 'x' + } + }) + editor.getBuffer().insert([1, Infinity], '\n ') + + runAnimationFrames() + + let line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2)) + expect(line2LeafNodes.length).toBe(4) + expect(line2LeafNodes[0].textContent).toBe('--') + expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe(true) + expect(line2LeafNodes[1].textContent).toBe('--') + expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe(true) + expect(line2LeafNodes[2].textContent).toBe('--') + expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe(true) + expect(line2LeafNodes[3].textContent).toBe('x') + }) + + it('does not render indent guides in trailing whitespace for lines containing non whitespace characters', function () { + editor.getBuffer().setText(' hi ') + + runAnimationFrames() + + let line0LeafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) + expect(line0LeafNodes[0].textContent).toBe(' ') + expect(line0LeafNodes[0].classList.contains('indent-guide')).toBe(true) + expect(line0LeafNodes[1].textContent).toBe(' ') + expect(line0LeafNodes[1].classList.contains('indent-guide')).toBe(false) + }) + + it('updates the indent guides on empty lines preceding an indentation change', function () { + editor.getBuffer().insert([12, 0], '\n') + runAnimationFrames() + + editor.getBuffer().insert([13, 0], ' ') + runAnimationFrames() + + let line12LeafNodes = getLeafNodes(component.lineNodeForScreenRow(12)) + expect(line12LeafNodes[0].textContent).toBe(' ') + expect(line12LeafNodes[0].classList.contains('indent-guide')).toBe(true) + expect(line12LeafNodes[1].textContent).toBe(' ') + expect(line12LeafNodes[1].classList.contains('indent-guide')).toBe(true) + }) + + it('updates the indent guides on empty lines following an indentation change', function () { + editor.getBuffer().insert([12, 2], '\n') + + runAnimationFrames() + + editor.getBuffer().insert([12, 0], ' ') + runAnimationFrames() + + let line13LeafNodes = getLeafNodes(component.lineNodeForScreenRow(13)) + expect(line13LeafNodes[0].textContent).toBe(' ') + expect(line13LeafNodes[0].classList.contains('indent-guide')).toBe(true) + expect(line13LeafNodes[1].textContent).toBe(' ') + expect(line13LeafNodes[1].classList.contains('indent-guide')).toBe(true) + }) + }) + + describe('when indent guides are disabled', function () { + beforeEach(function () { + expect(atom.config.get('editor.showIndentGuide')).toBe(false) + }) + + it('does not render indent guides on lines containing only whitespace', function () { + editor.getBuffer().insert([1, Infinity], '\n ') + + runAnimationFrames() + + let line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2)) + expect(line2LeafNodes.length).toBe(1) + expect(line2LeafNodes[0].textContent).toBe(' ') + expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe(false) + }) + }) + + describe('when the buffer contains null bytes', function () { + it('excludes the null byte from character measurement', function () { + editor.setText('a\0b') + runAnimationFrames() + expect(wrapperNode.pixelPositionForScreenPosition([0, Infinity]).left).toEqual(2 * charWidth) + }) + }) + + describe('when there is a fold', function () { + it('renders a fold marker on the folded line', function () { + let foldedLineNode = component.lineNodeForScreenRow(4) + expect(foldedLineNode.querySelector('.fold-marker')).toBeFalsy() + editor.foldBufferRow(4) + + runAnimationFrames() + + foldedLineNode = component.lineNodeForScreenRow(4) + expect(foldedLineNode.querySelector('.fold-marker')).toBeTruthy() + editor.unfoldBufferRow(4) + + runAnimationFrames() + + foldedLineNode = component.lineNodeForScreenRow(4) + expect(foldedLineNode.querySelector('.fold-marker')).toBeFalsy() + }) + }) + }) + + describe('gutter rendering', function () { + function expectTileContainsRow (tileNode, screenRow, {top, text}) { + let lineNode = tileNode.querySelector('[data-screen-row="' + screenRow + '"]') + expect(lineNode.offsetTop).toBe(top) + expect(lineNode.textContent).toBe(text) + } + + it('renders higher tiles in front of lower ones', function () { + wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames(true) + + let tilesNodes = component.tileNodesForLineNumbers() + expect(tilesNodes[0].style.zIndex).toBe('2') + expect(tilesNodes[1].style.zIndex).toBe('1') + expect(tilesNodes[2].style.zIndex).toBe('0') + verticalScrollbarNode.scrollTop = 1 * lineHeightInPixels + verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) + runAnimationFrames(true) + + tilesNodes = component.tileNodesForLineNumbers() + expect(tilesNodes[0].style.zIndex).toBe('3') + expect(tilesNodes[1].style.zIndex).toBe('2') + expect(tilesNodes[2].style.zIndex).toBe('1') + expect(tilesNodes[3].style.zIndex).toBe('0') + }) + + it('gives the line numbers container the same height as the wrapper node', function () { + let linesNode = componentNode.querySelector('.line-numbers') + wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + + runAnimationFrames() + + expect(linesNode.getBoundingClientRect().height).toBe(6.5 * lineHeightInPixels) + wrapperNode.style.height = 3.5 * lineHeightInPixels + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + + runAnimationFrames() + + expect(linesNode.getBoundingClientRect().height).toBe(3.5 * lineHeightInPixels) + }) + + it('renders the currently-visible line numbers in a tiled fashion', function () { + wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + let tilesNodes = component.tileNodesForLineNumbers() + expect(tilesNodes.length).toBe(3) + + expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, 0px, 0px)') + expect(tilesNodes[0].querySelectorAll('.line-number').length).toBe(3) + expectTileContainsRow(tilesNodes[0], 0, { + top: lineHeightInPixels * 0, + text: '' + NBSP + '1' + }) + expectTileContainsRow(tilesNodes[0], 1, { + top: lineHeightInPixels * 1, + text: '' + NBSP + '2' + }) + expectTileContainsRow(tilesNodes[0], 2, { + top: lineHeightInPixels * 2, + text: '' + NBSP + '3' + }) + + expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels) + 'px, 0px)') + expect(tilesNodes[1].querySelectorAll('.line-number').length).toBe(3) + expectTileContainsRow(tilesNodes[1], 3, { + top: lineHeightInPixels * 0, + text: '' + NBSP + '4' + }) + expectTileContainsRow(tilesNodes[1], 4, { + top: lineHeightInPixels * 1, + text: '' + NBSP + '5' + }) + expectTileContainsRow(tilesNodes[1], 5, { + top: lineHeightInPixels * 2, + text: '' + NBSP + '6' + }) + + expect(tilesNodes[2].style['-webkit-transform']).toBe('translate3d(0px, ' + (2 * tileHeightInPixels) + 'px, 0px)') + expect(tilesNodes[2].querySelectorAll('.line-number').length).toBe(3) + expectTileContainsRow(tilesNodes[2], 6, { + top: lineHeightInPixels * 0, + text: '' + NBSP + '7' + }) + expectTileContainsRow(tilesNodes[2], 7, { + top: lineHeightInPixels * 1, + text: '' + NBSP + '8' + }) + expectTileContainsRow(tilesNodes[2], 8, { + top: lineHeightInPixels * 2, + text: '' + NBSP + '9' + }) + verticalScrollbarNode.scrollTop = TILE_SIZE * lineHeightInPixels + 5 + verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) + runAnimationFrames(true) + + tilesNodes = component.tileNodesForLineNumbers() + expect(component.lineNumberNodeForScreenRow(2)).toBeUndefined() + expect(tilesNodes.length).toBe(3) + + expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, ' + (0 * tileHeightInPixels - 5) + 'px, 0px)') + expect(tilesNodes[0].querySelectorAll('.line-number').length).toBe(TILE_SIZE) + expectTileContainsRow(tilesNodes[0], 3, { + top: lineHeightInPixels * 0, + text: '' + NBSP + '4' + }) + expectTileContainsRow(tilesNodes[0], 4, { + top: lineHeightInPixels * 1, + text: '' + NBSP + '5' + }) + expectTileContainsRow(tilesNodes[0], 5, { + top: lineHeightInPixels * 2, + text: '' + NBSP + '6' + }) + + expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels - 5) + 'px, 0px)') + expect(tilesNodes[1].querySelectorAll('.line-number').length).toBe(TILE_SIZE) + expectTileContainsRow(tilesNodes[1], 6, { + top: 0 * lineHeightInPixels, + text: '' + NBSP + '7' + }) + expectTileContainsRow(tilesNodes[1], 7, { + top: 1 * lineHeightInPixels, + text: '' + NBSP + '8' + }) + expectTileContainsRow(tilesNodes[1], 8, { + top: 2 * lineHeightInPixels, + text: '' + NBSP + '9' + }) + + expect(tilesNodes[2].style['-webkit-transform']).toBe('translate3d(0px, ' + (2 * tileHeightInPixels - 5) + 'px, 0px)') + expect(tilesNodes[2].querySelectorAll('.line-number').length).toBe(TILE_SIZE) + expectTileContainsRow(tilesNodes[2], 9, { + top: 0 * lineHeightInPixels, + text: '10' + }) + expectTileContainsRow(tilesNodes[2], 10, { + top: 1 * lineHeightInPixels, + text: '11' + }) + expectTileContainsRow(tilesNodes[2], 11, { + top: 2 * lineHeightInPixels, + text: '12' + }) + }) + + it('updates the translation of subsequent line numbers when lines are inserted or removed', function () { + editor.getBuffer().insert([0, 0], '\n\n') + runAnimationFrames() + + let lineNumberNodes = componentNode.querySelectorAll('.line-number') + expect(component.lineNumberNodeForScreenRow(0).offsetTop).toBe(0 * lineHeightInPixels) + expect(component.lineNumberNodeForScreenRow(1).offsetTop).toBe(1 * lineHeightInPixels) + expect(component.lineNumberNodeForScreenRow(2).offsetTop).toBe(2 * lineHeightInPixels) + expect(component.lineNumberNodeForScreenRow(3).offsetTop).toBe(0 * lineHeightInPixels) + expect(component.lineNumberNodeForScreenRow(4).offsetTop).toBe(1 * lineHeightInPixels) + expect(component.lineNumberNodeForScreenRow(5).offsetTop).toBe(2 * lineHeightInPixels) + editor.getBuffer().insert([0, 0], '\n\n') + + runAnimationFrames() + + expect(component.lineNumberNodeForScreenRow(0).offsetTop).toBe(0 * lineHeightInPixels) + expect(component.lineNumberNodeForScreenRow(1).offsetTop).toBe(1 * lineHeightInPixels) + expect(component.lineNumberNodeForScreenRow(2).offsetTop).toBe(2 * lineHeightInPixels) + expect(component.lineNumberNodeForScreenRow(3).offsetTop).toBe(0 * lineHeightInPixels) + expect(component.lineNumberNodeForScreenRow(4).offsetTop).toBe(1 * lineHeightInPixels) + expect(component.lineNumberNodeForScreenRow(5).offsetTop).toBe(2 * lineHeightInPixels) + expect(component.lineNumberNodeForScreenRow(6).offsetTop).toBe(0 * lineHeightInPixels) + expect(component.lineNumberNodeForScreenRow(7).offsetTop).toBe(1 * lineHeightInPixels) + expect(component.lineNumberNodeForScreenRow(8).offsetTop).toBe(2 * lineHeightInPixels) + }) + + it('renders • characters for soft-wrapped lines', function () { + editor.setSoftWrapped(true) + wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' + wrapperNode.style.width = 30 * charWidth + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + + runAnimationFrames() + + expect(componentNode.querySelectorAll('.line-number').length).toBe(9 + 1) + 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 + '•') + expect(component.lineNumberNodeForScreenRow(6).textContent).toBe('' + NBSP + '4') + expect(component.lineNumberNodeForScreenRow(7).textContent).toBe('' + NBSP + '•') + expect(component.lineNumberNodeForScreenRow(8).textContent).toBe('' + NBSP + '•') + }) + + it('pads line numbers to be right-justified based on the maximum number of line number digits', function () { + const input = []; + for (let i = 1; i <= 100; ++i) { + input.push(i); + } + editor.getBuffer().setText(input.join('\n')) + runAnimationFrames() + + for (let screenRow = 0; screenRow <= 8; ++screenRow) { + expect(component.lineNumberNodeForScreenRow(screenRow).textContent).toBe('' + NBSP + NBSP + (screenRow + 1)) + } + expect(component.lineNumberNodeForScreenRow(99).textContent).toBe('100') + let gutterNode = componentNode.querySelector('.gutter') + let initialGutterWidth = gutterNode.offsetWidth + editor.getBuffer().delete([[1, 0], [2, 0]]) + + runAnimationFrames() + + for (let screenRow = 0; screenRow <= 8; ++screenRow) { + expect(component.lineNumberNodeForScreenRow(screenRow).textContent).toBe('' + NBSP + (screenRow + 1)) + } + expect(gutterNode.offsetWidth).toBeLessThan(initialGutterWidth) + editor.getBuffer().insert([0, 0], '\n\n') + + runAnimationFrames() + + for (let screenRow = 0; screenRow <= 8; ++screenRow) { + expect(component.lineNumberNodeForScreenRow(screenRow).textContent).toBe('' + NBSP + NBSP + (screenRow + 1)) + } + expect(component.lineNumberNodeForScreenRow(99).textContent).toBe('100') + expect(gutterNode.offsetWidth).toBe(initialGutterWidth) + }) + + it('renders the .line-numbers div at the full height of the editor even if it\'s taller than its content', function () { + wrapperNode.style.height = componentNode.offsetHeight + 100 + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + expect(componentNode.querySelector('.line-numbers').offsetHeight).toBe(componentNode.offsetHeight) + }) + + it('applies the background color of the gutter or the editor to the line numbers to improve GPU performance', function () { + let gutterNode = componentNode.querySelector('.gutter') + let lineNumbersNode = gutterNode.querySelector('.line-numbers') + let backgroundColor = getComputedStyle(wrapperNode).backgroundColor + expect(getComputedStyle(lineNumbersNode).backgroundColor).toBe(backgroundColor) + for (let tileNode of component.tileNodesForLineNumbers()) { + expect(getComputedStyle(tileNode).backgroundColor).toBe(backgroundColor) + } + + gutterNode.style.backgroundColor = 'rgb(255, 0, 0)' + runAnimationFrames() + + expect(getComputedStyle(lineNumbersNode).backgroundColor).toBe('rgb(255, 0, 0)') + for (let tileNode of component.tileNodesForLineNumbers()) { + expect(getComputedStyle(tileNode).backgroundColor).toBe('rgb(255, 0, 0)') + } + }) + + it('hides or shows the gutter based on the "::isLineNumberGutterVisible" property on the model and the global "editor.showLineNumbers" config setting', function () { + expect(component.gutterContainerComponent.getLineNumberGutterComponent() != null).toBe(true) + editor.setLineNumberGutterVisible(false) + runAnimationFrames() + + expect(componentNode.querySelector('.gutter').style.display).toBe('none') + editor.update({showLineNumbers: false}) + runAnimationFrames() + + expect(componentNode.querySelector('.gutter').style.display).toBe('none') + editor.setLineNumberGutterVisible(true) + runAnimationFrames() + + expect(componentNode.querySelector('.gutter').style.display).toBe('none') + editor.update({showLineNumbers: true}) + runAnimationFrames() + + expect(componentNode.querySelector('.gutter').style.display).toBe('') + expect(component.lineNumberNodeForScreenRow(3) != null).toBe(true) + }) + + it('keeps rebuilding line numbers when continuous reflow is on', function () { + wrapperNode.setContinuousReflow(true) + let oldLineNode = componentNode.querySelectorAll('.line-number')[1] + + while (true) { + runAnimationFrames() + if (componentNode.querySelectorAll('.line-number')[1] !== oldLineNode) break + } + }) + + describe('fold decorations', function () { + describe('rendering fold decorations', function () { + it('adds the foldable class to line numbers when the line is foldable', function () { + expect(lineNumberHasClass(0, 'foldable')).toBe(true) + expect(lineNumberHasClass(1, 'foldable')).toBe(true) + expect(lineNumberHasClass(2, 'foldable')).toBe(false) + expect(lineNumberHasClass(3, 'foldable')).toBe(false) + expect(lineNumberHasClass(4, 'foldable')).toBe(true) + expect(lineNumberHasClass(5, 'foldable')).toBe(false) + }) + + it('updates the foldable class on the correct line numbers when the foldable positions change', function () { + editor.getBuffer().insert([0, 0], '\n') + runAnimationFrames() + + expect(lineNumberHasClass(0, 'foldable')).toBe(false) + expect(lineNumberHasClass(1, 'foldable')).toBe(true) + expect(lineNumberHasClass(2, 'foldable')).toBe(true) + expect(lineNumberHasClass(3, 'foldable')).toBe(false) + expect(lineNumberHasClass(4, 'foldable')).toBe(false) + expect(lineNumberHasClass(5, 'foldable')).toBe(true) + expect(lineNumberHasClass(6, 'foldable')).toBe(false) + }) + + it('updates the foldable class on a line number that becomes foldable', function () { + expect(lineNumberHasClass(11, 'foldable')).toBe(false) + editor.getBuffer().insert([11, 44], '\n fold me') + runAnimationFrames() + expect(lineNumberHasClass(11, 'foldable')).toBe(true) + editor.undo() + runAnimationFrames() + expect(lineNumberHasClass(11, 'foldable')).toBe(false) + }) + + it('adds, updates and removes the folded class on the correct line number componentNodes', function () { + editor.foldBufferRow(4) + runAnimationFrames() + + expect(lineNumberHasClass(4, 'folded')).toBe(true) + + editor.getBuffer().insert([0, 0], '\n') + runAnimationFrames() + + expect(lineNumberHasClass(4, 'folded')).toBe(false) + expect(lineNumberHasClass(5, 'folded')).toBe(true) + + editor.unfoldBufferRow(5) + runAnimationFrames() + + expect(lineNumberHasClass(5, 'folded')).toBe(false) + }) + + describe('when soft wrapping is enabled', function () { + beforeEach(function () { + editor.setSoftWrapped(true) + runAnimationFrames() + componentNode.style.width = 20 * charWidth + wrapperNode.getVerticalScrollbarWidth() + 'px' + component.measureDimensions() + runAnimationFrames() + }) + + it('does not add the foldable class for soft-wrapped lines', function () { + expect(lineNumberHasClass(0, 'foldable')).toBe(true) + expect(lineNumberHasClass(1, 'foldable')).toBe(false) + }) + + it('does not add the folded class for soft-wrapped lines that contain a fold', function () { + editor.foldBufferRange([[3, 19], [3, 21]]) + runAnimationFrames() + + expect(lineNumberHasClass(11, 'folded')).toBe(true) + expect(lineNumberHasClass(12, 'folded')).toBe(false) + }) + }) + }) + + describe('mouse interactions with fold indicators', function () { + let gutterNode + + function buildClickEvent (target) { + return buildMouseEvent('click', { + target: target + }) + } + + beforeEach(function () { + gutterNode = componentNode.querySelector('.gutter') + }) + + describe('when the component is destroyed', function () { + it('stops listening for folding events', function () { + let lineNumber, target + component.destroy() + lineNumber = component.lineNumberNodeForScreenRow(1) + target = lineNumber.querySelector('.icon-right') + target.dispatchEvent(buildClickEvent(target)) + }) + }) + + it('folds and unfolds the block represented by the fold indicator when clicked', function () { + expect(lineNumberHasClass(1, 'folded')).toBe(false) + + let lineNumber = component.lineNumberNodeForScreenRow(1) + let target = lineNumber.querySelector('.icon-right') + + target.dispatchEvent(buildClickEvent(target)) + + runAnimationFrames() + + expect(lineNumberHasClass(1, 'folded')).toBe(true) + lineNumber = component.lineNumberNodeForScreenRow(1) + target = lineNumber.querySelector('.icon-right') + target.dispatchEvent(buildClickEvent(target)) + + runAnimationFrames() + + expect(lineNumberHasClass(1, 'folded')).toBe(false) + }) + + it('unfolds all the free-form folds intersecting the buffer row when clicked', function () { + expect(lineNumberHasClass(3, 'foldable')).toBe(false) + + editor.foldBufferRange([[3, 4], [5, 4]]) + editor.foldBufferRange([[5, 5], [8, 10]]) + runAnimationFrames() + expect(lineNumberHasClass(3, 'folded')).toBe(true) + expect(lineNumberHasClass(5, 'folded')).toBe(false) + + let lineNumber = component.lineNumberNodeForScreenRow(3) + let target = lineNumber.querySelector('.icon-right') + target.dispatchEvent(buildClickEvent(target)) + runAnimationFrames() + expect(lineNumberHasClass(3, 'folded')).toBe(false) + expect(lineNumberHasClass(5, 'folded')).toBe(true) + + editor.setSoftWrapped(true) + componentNode.style.width = 20 * charWidth + wrapperNode.getVerticalScrollbarWidth() + 'px' + component.measureDimensions() + runAnimationFrames() + editor.foldBufferRange([[3, 19], [3, 21]]) // fold starting on a soft-wrapped portion of the line + runAnimationFrames() + expect(lineNumberHasClass(11, 'folded')).toBe(true) + + lineNumber = component.lineNumberNodeForScreenRow(11) + target = lineNumber.querySelector('.icon-right') + target.dispatchEvent(buildClickEvent(target)) + runAnimationFrames() + expect(lineNumberHasClass(11, 'folded')).toBe(false) + }) + + it('does not fold when the line number componentNode is clicked', function () { + let lineNumber = component.lineNumberNodeForScreenRow(1) + lineNumber.dispatchEvent(buildClickEvent(lineNumber)) + waits(100) + runs(function () { + expect(lineNumberHasClass(1, 'folded')).toBe(false) + }) + }) + }) + }) + }) + + describe('cursor rendering', function () { + it('renders the currently visible cursors', function () { + let cursor1 = editor.getLastCursor() + cursor1.setScreenPosition([0, 5], { + autoscroll: false + }) + wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' + wrapperNode.style.width = 20 * lineHeightInPixels + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + let cursorNodes = componentNode.querySelectorAll('.cursor') + expect(cursorNodes.length).toBe(1) + expect(cursorNodes[0].offsetHeight).toBe(lineHeightInPixels) + expect(cursorNodes[0].offsetWidth).toBeCloseTo(charWidth, 0) + expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(5 * charWidth)) + 'px, ' + (0 * lineHeightInPixels) + 'px)') + let cursor2 = editor.addCursorAtScreenPosition([8, 11], { + autoscroll: false + }) + let cursor3 = editor.addCursorAtScreenPosition([4, 10], { + autoscroll: false + }) + runAnimationFrames() + + cursorNodes = componentNode.querySelectorAll('.cursor') + expect(cursorNodes.length).toBe(2) + expect(cursorNodes[0].offsetTop).toBe(0) + expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(5 * charWidth)) + 'px, ' + (0 * lineHeightInPixels) + 'px)') + expect(cursorNodes[1].style['-webkit-transform']).toBe('translate(' + (Math.round(10 * charWidth)) + 'px, ' + (4 * lineHeightInPixels) + 'px)') + verticalScrollbarNode.scrollTop = 4.5 * lineHeightInPixels + verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) + runAnimationFrames(true) + + horizontalScrollbarNode.scrollLeft = 3.5 * charWidth + horizontalScrollbarNode.dispatchEvent(new UIEvent('scroll')) + runAnimationFrames(true) + + cursorNodes = componentNode.querySelectorAll('.cursor') + expect(cursorNodes.length).toBe(2) + expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(10 * charWidth - horizontalScrollbarNode.scrollLeft)) + 'px, ' + (4 * lineHeightInPixels - verticalScrollbarNode.scrollTop) + 'px)') + expect(cursorNodes[1].style['-webkit-transform']).toBe('translate(' + (Math.round(11 * charWidth - horizontalScrollbarNode.scrollLeft)) + 'px, ' + (8 * lineHeightInPixels - verticalScrollbarNode.scrollTop) + 'px)') + editor.onDidChangeCursorPosition(cursorMovedListener = jasmine.createSpy('cursorMovedListener')) + cursor3.setScreenPosition([4, 11], { + autoscroll: false + }) + runAnimationFrames() + + expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(11 * charWidth - horizontalScrollbarNode.scrollLeft)) + 'px, ' + (4 * lineHeightInPixels - verticalScrollbarNode.scrollTop) + 'px)') + expect(cursorMovedListener).toHaveBeenCalled() + cursor3.destroy() + runAnimationFrames() + + cursorNodes = componentNode.querySelectorAll('.cursor') + expect(cursorNodes.length).toBe(1) + expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(11 * charWidth - horizontalScrollbarNode.scrollLeft)) + 'px, ' + (8 * lineHeightInPixels - verticalScrollbarNode.scrollTop) + 'px)') + }) + + it('accounts for character widths when positioning cursors', function () { + component.setFontFamily('sans-serif') + editor.setCursorScreenPosition([0, 16]) + runAnimationFrames() + + let cursor = componentNode.querySelector('.cursor') + let cursorRect = cursor.getBoundingClientRect() + let cursorLocationTextNode = component.lineNodeForScreenRow(0).querySelector('.syntax--storage.syntax--type.syntax--function.syntax--js').firstChild + let range = document.createRange() + range.setStart(cursorLocationTextNode, 0) + range.setEnd(cursorLocationTextNode, 1) + let rangeRect = range.getBoundingClientRect() + expect(cursorRect.left).toBeCloseTo(rangeRect.left, 0) + expect(cursorRect.width).toBeCloseTo(rangeRect.width, 0) + }) + + it('accounts for the width of paired characters when positioning cursors', function () { + component.setFontFamily('sans-serif') + editor.setText('he\u0301y') + editor.setCursorBufferPosition([0, 3]) + runAnimationFrames() + + let cursor = componentNode.querySelector('.cursor') + let cursorRect = cursor.getBoundingClientRect() + let cursorLocationTextNode = component.lineNodeForScreenRow(0).querySelector('.syntax--source.syntax--js').childNodes[0] + let range = document.createRange(cursorLocationTextNode) + range.setStart(cursorLocationTextNode, 3) + range.setEnd(cursorLocationTextNode, 4) + let rangeRect = range.getBoundingClientRect() + expect(cursorRect.left).toBeCloseTo(rangeRect.left, 0) + expect(cursorRect.width).toBeCloseTo(rangeRect.width, 0) + }) + + it('positions cursors after the fold-marker when a fold ends the line', function () { + editor.foldBufferRow(0) + runAnimationFrames() + editor.setCursorScreenPosition([0, 30]) + runAnimationFrames() + + let cursorRect = componentNode.querySelector('.cursor').getBoundingClientRect() + let foldMarkerRect = componentNode.querySelector('.fold-marker').getBoundingClientRect() + expect(cursorRect.left).toBeCloseTo(foldMarkerRect.right, 0) + }) + + it('positions cursors correctly after character widths are changed via a stylesheet change', function () { + component.setFontFamily('sans-serif') + editor.setCursorScreenPosition([0, 16]) + runAnimationFrames(true) + + atom.styles.addStyleSheet('.syntax--function.syntax--js {\n font-weight: bold;\n}', { + context: 'atom-text-editor' + }) + runAnimationFrames(true) + + let cursor = componentNode.querySelector('.cursor') + let cursorRect = cursor.getBoundingClientRect() + let cursorLocationTextNode = component.lineNodeForScreenRow(0).querySelector('.syntax--storage.syntax--type.syntax--function.syntax--js').firstChild + let range = document.createRange() + range.setStart(cursorLocationTextNode, 0) + range.setEnd(cursorLocationTextNode, 1) + let rangeRect = range.getBoundingClientRect() + expect(cursorRect.left).toBeCloseTo(rangeRect.left, 0) + expect(cursorRect.width).toBeCloseTo(rangeRect.width, 0) + atom.themes.removeStylesheet('test') + }) + + it('sets the cursor to the default character width at the end of a line', function () { + editor.setCursorScreenPosition([0, Infinity]) + runAnimationFrames() + let cursorNode = componentNode.querySelector('.cursor') + expect(cursorNode.offsetWidth).toBeCloseTo(charWidth, 0) + }) + + it('gives the cursor a non-zero width even if it\'s inside atomic tokens', function () { + editor.setCursorScreenPosition([1, 0]) + runAnimationFrames() + let cursorNode = componentNode.querySelector('.cursor') + expect(cursorNode.offsetWidth).toBeCloseTo(charWidth, 0) + }) + + it('blinks cursors when they are not moving', async function () { + let cursorsNode = componentNode.querySelector('.cursors') + wrapperNode.focus() + runAnimationFrames() + expect(cursorsNode.classList.contains('blink-off')).toBe(false) + advanceClock(component.cursorBlinkPeriod / 2) + runAnimationFrames() + expect(cursorsNode.classList.contains('blink-off')).toBe(true) + advanceClock(component.cursorBlinkPeriod / 2) + runAnimationFrames() + expect(cursorsNode.classList.contains('blink-off')).toBe(false) + editor.moveRight() + runAnimationFrames() + expect(cursorsNode.classList.contains('blink-off')).toBe(false) + advanceClock(component.cursorBlinkResumeDelay) + runAnimationFrames(true) + expect(cursorsNode.classList.contains('blink-off')).toBe(false) + advanceClock(component.cursorBlinkPeriod / 2) + runAnimationFrames() + expect(cursorsNode.classList.contains('blink-off')).toBe(true) + }) + + it('renders cursors that are associated with empty selections', function () { + editor.update({showCursorOnSelection: true}) + editor.setSelectedScreenRange([[0, 4], [4, 6]]) + editor.addCursorAtScreenPosition([6, 8]) + runAnimationFrames() + let cursorNodes = componentNode.querySelectorAll('.cursor') + expect(cursorNodes.length).toBe(2) + expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(6 * charWidth)) + 'px, ' + (4 * lineHeightInPixels) + 'px)') + expect(cursorNodes[1].style['-webkit-transform']).toBe('translate(' + (Math.round(8 * charWidth)) + 'px, ' + (6 * lineHeightInPixels) + 'px)') + }) + + it('does not render cursors that are associated with non-empty selections when showCursorOnSelection is false', function () { + editor.update({showCursorOnSelection: false}) + editor.setSelectedScreenRange([[0, 4], [4, 6]]) + editor.addCursorAtScreenPosition([6, 8]) + runAnimationFrames() + let cursorNodes = componentNode.querySelectorAll('.cursor') + expect(cursorNodes.length).toBe(1) + expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(8 * charWidth)) + 'px, ' + (6 * lineHeightInPixels) + 'px)') + }) + + it('updates cursor positions when the line height changes', function () { + editor.setCursorBufferPosition([1, 10]) + component.setLineHeight(2) + runAnimationFrames() + let cursorNode = componentNode.querySelector('.cursor') + expect(cursorNode.style['-webkit-transform']).toBe('translate(' + (Math.round(10 * editor.getDefaultCharWidth())) + 'px, ' + (editor.getLineHeightInPixels()) + 'px)') + }) + + it('updates cursor positions when the font size changes', function () { + editor.setCursorBufferPosition([1, 10]) + component.setFontSize(10) + runAnimationFrames() + let cursorNode = componentNode.querySelector('.cursor') + expect(cursorNode.style['-webkit-transform']).toBe('translate(' + (Math.round(10 * editor.getDefaultCharWidth())) + 'px, ' + (editor.getLineHeightInPixels()) + 'px)') + }) + + it('updates cursor positions when the font family changes', function () { + editor.setCursorBufferPosition([1, 10]) + component.setFontFamily('sans-serif') + runAnimationFrames() + let cursorNode = componentNode.querySelector('.cursor') + let left = wrapperNode.pixelPositionForScreenPosition([1, 10]).left + expect(cursorNode.style['-webkit-transform']).toBe('translate(' + (Math.round(left)) + 'px, ' + (editor.getLineHeightInPixels()) + 'px)') + }) + }) + + describe('selection rendering', function () { + let scrollViewClientLeft, scrollViewNode + + beforeEach(function () { + scrollViewNode = componentNode.querySelector('.scroll-view') + scrollViewClientLeft = componentNode.querySelector('.scroll-view').getBoundingClientRect().left + }) + + it('renders 1 region for 1-line selections', function () { + editor.setSelectedScreenRange([[1, 6], [1, 10]]) + runAnimationFrames() + + let regions = componentNode.querySelectorAll('.selection .region') + expect(regions.length).toBe(1) + + let regionRect = regions[0].getBoundingClientRect() + expect(regionRect.top).toBe(1 * lineHeightInPixels) + expect(regionRect.height).toBe(1 * lineHeightInPixels) + expect(regionRect.left).toBeCloseTo(scrollViewClientLeft + 6 * charWidth, 0) + expect(regionRect.width).toBeCloseTo(4 * charWidth, 0) + }) + + it('renders 2 regions for 2-line selections', function () { + editor.setSelectedScreenRange([[1, 6], [2, 10]]) + runAnimationFrames() + + let tileNode = component.tileNodesForLines()[0] + let regions = tileNode.querySelectorAll('.selection .region') + expect(regions.length).toBe(2) + + let region1Rect = regions[0].getBoundingClientRect() + expect(region1Rect.top).toBe(1 * lineHeightInPixels) + expect(region1Rect.height).toBe(1 * lineHeightInPixels) + expect(region1Rect.left).toBeCloseTo(scrollViewClientLeft + 6 * charWidth, 0) + expect(region1Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0) + + let region2Rect = regions[1].getBoundingClientRect() + expect(region2Rect.top).toBe(2 * lineHeightInPixels) + expect(region2Rect.height).toBe(1 * lineHeightInPixels) + expect(region2Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0) + expect(region2Rect.width).toBeCloseTo(10 * charWidth, 0) + }) + + it('renders 3 regions per tile for selections with more than 2 lines', function () { + editor.setSelectedScreenRange([[0, 6], [5, 10]]) + runAnimationFrames() + + let region1Rect, region2Rect, region3Rect, regions, tileNode + tileNode = component.tileNodesForLines()[0] + regions = tileNode.querySelectorAll('.selection .region') + expect(regions.length).toBe(3) + + region1Rect = regions[0].getBoundingClientRect() + expect(region1Rect.top).toBe(0) + expect(region1Rect.height).toBe(1 * lineHeightInPixels) + expect(region1Rect.left).toBeCloseTo(scrollViewClientLeft + 6 * charWidth, 0) + expect(region1Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0) + + region2Rect = regions[1].getBoundingClientRect() + expect(region2Rect.top).toBe(1 * lineHeightInPixels) + expect(region2Rect.height).toBe(1 * lineHeightInPixels) + expect(region2Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0) + expect(region2Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0) + + region3Rect = regions[2].getBoundingClientRect() + expect(region3Rect.top).toBe(2 * lineHeightInPixels) + expect(region3Rect.height).toBe(1 * lineHeightInPixels) + expect(region3Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0) + expect(region3Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0) + + tileNode = component.tileNodesForLines()[1] + regions = tileNode.querySelectorAll('.selection .region') + expect(regions.length).toBe(3) + + region1Rect = regions[0].getBoundingClientRect() + expect(region1Rect.top).toBe(3 * lineHeightInPixels) + expect(region1Rect.height).toBe(1 * lineHeightInPixels) + expect(region1Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0) + expect(region1Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0) + + region2Rect = regions[1].getBoundingClientRect() + expect(region2Rect.top).toBe(4 * lineHeightInPixels) + expect(region2Rect.height).toBe(1 * lineHeightInPixels) + expect(region2Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0) + expect(region2Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0) + + region3Rect = regions[2].getBoundingClientRect() + expect(region3Rect.top).toBe(5 * lineHeightInPixels) + expect(region3Rect.height).toBe(1 * lineHeightInPixels) + expect(region3Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0) + expect(region3Rect.width).toBeCloseTo(10 * charWidth, 0) + }) + + it('does not render empty selections', function () { + editor.addSelectionForBufferRange([[2, 2], [2, 2]]) + runAnimationFrames() + expect(editor.getSelections()[0].isEmpty()).toBe(true) + expect(editor.getSelections()[1].isEmpty()).toBe(true) + expect(componentNode.querySelectorAll('.selection').length).toBe(0) + }) + + it('updates selections when the line height changes', function () { + editor.setSelectedBufferRange([[1, 6], [1, 10]]) + component.setLineHeight(2) + runAnimationFrames() + let selectionNode = componentNode.querySelector('.region') + expect(selectionNode.offsetTop).toBe(editor.getLineHeightInPixels()) + }) + + it('updates selections when the font size changes', function () { + editor.setSelectedBufferRange([[1, 6], [1, 10]]) + component.setFontSize(10) + + runAnimationFrames() + + let selectionNode = componentNode.querySelector('.region') + expect(selectionNode.offsetTop).toBe(editor.getLineHeightInPixels()) + expect(selectionNode.offsetLeft).toBeCloseTo(6 * editor.getDefaultCharWidth(), 0) + }) + + it('updates selections when the font family changes', function () { + editor.setSelectedBufferRange([[1, 6], [1, 10]]) + component.setFontFamily('sans-serif') + + runAnimationFrames() + + let selectionNode = componentNode.querySelector('.region') + expect(selectionNode.offsetTop).toBe(editor.getLineHeightInPixels()) + expect(selectionNode.offsetLeft).toBeCloseTo(wrapperNode.pixelPositionForScreenPosition([1, 6]).left, 0) + }) + + it('will flash the selection when flash:true is passed to editor::setSelectedBufferRange', async function () { + editor.setSelectedBufferRange([[1, 6], [1, 10]], { + flash: true + }) + runAnimationFrames() + + let selectionNode = componentNode.querySelector('.selection') + expect(selectionNode.classList.contains('flash')).toBe(true) + + advanceClock(editor.selectionFlashDuration) + + editor.setSelectedBufferRange([[1, 5], [1, 7]], { + flash: true + }) + runAnimationFrames() + + expect(selectionNode.classList.contains('flash')).toBe(true) + }) + }) + + describe('line decoration rendering', async function () { + let decoration, marker + + beforeEach(async function () { + marker = editor.addMarkerLayer({ + maintainHistory: true + }).markBufferRange([[2, 13], [3, 15]], { + invalidate: 'inside' + }) + decoration = editor.decorateMarker(marker, { + type: ['line-number', 'line'], + 'class': 'a' + }) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + }) + + it('applies line decoration classes to lines and line numbers', async function () { + expect(lineAndLineNumberHaveClass(2, 'a')).toBe(true) + expect(lineAndLineNumberHaveClass(3, 'a')).toBe(true) + wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + let marker2 = editor.markBufferRange([[9, 0], [9, 0]]) + editor.decorateMarker(marker2, { + type: ['line-number', 'line'], + 'class': 'b' + }) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + verticalScrollbarNode.scrollTop = 4.5 * lineHeightInPixels + verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) + runAnimationFrames(true) + + expect(lineAndLineNumberHaveClass(9, 'b')).toBe(true) + + editor.foldBufferRow(5) + runAnimationFrames() + + expect(lineAndLineNumberHaveClass(9, 'b')).toBe(false) + expect(lineAndLineNumberHaveClass(6, 'b')).toBe(true) + }) + + it('only applies decorations to screen rows that are spanned by their marker when lines are soft-wrapped', async function () { + editor.setText('a line that wraps, ok') + editor.setSoftWrapped(true) + componentNode.style.width = 16 * charWidth + 'px' + component.measureDimensions() + + runAnimationFrames() + marker.destroy() + marker = editor.markBufferRange([[0, 0], [0, 2]]) + editor.decorateMarker(marker, { + type: ['line-number', 'line'], + 'class': 'b' + }) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + expect(lineNumberHasClass(0, 'b')).toBe(true) + expect(lineNumberHasClass(1, 'b')).toBe(false) + marker.setBufferRange([[0, 0], [0, Infinity]]) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + expect(lineNumberHasClass(0, 'b')).toBe(true) + expect(lineNumberHasClass(1, 'b')).toBe(true) + }) + + it('updates decorations when markers move', async function () { + expect(lineAndLineNumberHaveClass(1, 'a')).toBe(false) + expect(lineAndLineNumberHaveClass(2, 'a')).toBe(true) + expect(lineAndLineNumberHaveClass(3, 'a')).toBe(true) + expect(lineAndLineNumberHaveClass(4, 'a')).toBe(false) + + editor.getBuffer().insert([0, 0], '\n') + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + expect(lineAndLineNumberHaveClass(2, 'a')).toBe(false) + expect(lineAndLineNumberHaveClass(3, 'a')).toBe(true) + expect(lineAndLineNumberHaveClass(4, 'a')).toBe(true) + expect(lineAndLineNumberHaveClass(5, 'a')).toBe(false) + + marker.setBufferRange([[4, 4], [6, 4]]) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + expect(lineAndLineNumberHaveClass(2, 'a')).toBe(false) + expect(lineAndLineNumberHaveClass(3, 'a')).toBe(false) + expect(lineAndLineNumberHaveClass(4, 'a')).toBe(true) + expect(lineAndLineNumberHaveClass(5, 'a')).toBe(true) + expect(lineAndLineNumberHaveClass(6, 'a')).toBe(true) + expect(lineAndLineNumberHaveClass(7, 'a')).toBe(false) + }) + + it('remove decoration classes when decorations are removed', async function () { + decoration.destroy() + await decorationsUpdatedPromise(editor) + runAnimationFrames() + expect(lineNumberHasClass(1, 'a')).toBe(false) + expect(lineNumberHasClass(2, 'a')).toBe(false) + expect(lineNumberHasClass(3, 'a')).toBe(false) + expect(lineNumberHasClass(4, 'a')).toBe(false) + }) + + it('removes decorations when their marker is invalidated', async function () { + editor.getBuffer().insert([3, 2], 'n') + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + expect(marker.isValid()).toBe(false) + expect(lineAndLineNumberHaveClass(1, 'a')).toBe(false) + expect(lineAndLineNumberHaveClass(2, 'a')).toBe(false) + expect(lineAndLineNumberHaveClass(3, 'a')).toBe(false) + expect(lineAndLineNumberHaveClass(4, 'a')).toBe(false) + editor.undo() + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + expect(marker.isValid()).toBe(true) + expect(lineAndLineNumberHaveClass(1, 'a')).toBe(false) + expect(lineAndLineNumberHaveClass(2, 'a')).toBe(true) + expect(lineAndLineNumberHaveClass(3, 'a')).toBe(true) + expect(lineAndLineNumberHaveClass(4, 'a')).toBe(false) + }) + + it('removes decorations when their marker is destroyed', async function () { + marker.destroy() + await decorationsUpdatedPromise(editor) + runAnimationFrames() + expect(lineNumberHasClass(1, 'a')).toBe(false) + expect(lineNumberHasClass(2, 'a')).toBe(false) + expect(lineNumberHasClass(3, 'a')).toBe(false) + expect(lineNumberHasClass(4, 'a')).toBe(false) + }) + + describe('when the decoration\'s "onlyHead" property is true', async function () { + it('only applies the decoration\'s class to lines containing the marker\'s head', async function () { + editor.decorateMarker(marker, { + type: ['line-number', 'line'], + 'class': 'only-head', + onlyHead: true + }) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + expect(lineAndLineNumberHaveClass(1, 'only-head')).toBe(false) + expect(lineAndLineNumberHaveClass(2, 'only-head')).toBe(false) + expect(lineAndLineNumberHaveClass(3, 'only-head')).toBe(true) + expect(lineAndLineNumberHaveClass(4, 'only-head')).toBe(false) + }) + }) + + describe('when the decoration\'s "onlyEmpty" property is true', function () { + it('only applies the decoration when its marker is empty', async function () { + editor.decorateMarker(marker, { + type: ['line-number', 'line'], + 'class': 'only-empty', + onlyEmpty: true + }) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + expect(lineAndLineNumberHaveClass(2, 'only-empty')).toBe(false) + expect(lineAndLineNumberHaveClass(3, 'only-empty')).toBe(false) + + marker.clearTail() + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + expect(lineAndLineNumberHaveClass(2, 'only-empty')).toBe(false) + expect(lineAndLineNumberHaveClass(3, 'only-empty')).toBe(true) + }) + }) + + describe('when the decoration\'s "onlyNonEmpty" property is true', function () { + it('only applies the decoration when its marker is non-empty', async function () { + editor.decorateMarker(marker, { + type: ['line-number', 'line'], + 'class': 'only-non-empty', + onlyNonEmpty: true + }) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + expect(lineAndLineNumberHaveClass(2, 'only-non-empty')).toBe(true) + expect(lineAndLineNumberHaveClass(3, 'only-non-empty')).toBe(true) + + marker.clearTail() + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + expect(lineAndLineNumberHaveClass(2, 'only-non-empty')).toBe(false) + expect(lineAndLineNumberHaveClass(3, 'only-non-empty')).toBe(false) + }) + }) + }) + + describe('block decorations rendering', function () { + let markerLayer + + function createBlockDecorationBeforeScreenRow(screenRow, {className}) { + let item = document.createElement("div") + item.className = className || "" + let blockDecoration = editor.decorateMarker( + markerLayer.markScreenPosition([screenRow, 0], {invalidate: "never"}), + {type: "block", item: item, position: "before"} + ) + return [item, blockDecoration] + } + + function createBlockDecorationAfterScreenRow(screenRow, {className}) { + let item = document.createElement("div") + item.className = className || "" + let blockDecoration = editor.decorateMarker( + markerLayer.markScreenPosition([screenRow, 0], {invalidate: "never"}), + {type: "block", item: item, position: "after"} + ) + return [item, blockDecoration] + } + + beforeEach(function () { + markerLayer = editor.addMarkerLayer() + wrapperNode.style.height = 5 * lineHeightInPixels + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + }) + + afterEach(function () { + atom.themes.removeStylesheet('test') + }) + + it("renders visible and yet-to-be-measured block decorations, inserting them between the appropriate lines and refreshing them as needed", async function () { + let [item1, blockDecoration1] = createBlockDecorationBeforeScreenRow(0, {className: "decoration-1"}) + let [item2, blockDecoration2] = createBlockDecorationBeforeScreenRow(2, {className: "decoration-2"}) + let [item3, blockDecoration3] = createBlockDecorationBeforeScreenRow(4, {className: "decoration-3"}) + let [item4, blockDecoration4] = createBlockDecorationBeforeScreenRow(7, {className: "decoration-4"}) + let [item5, blockDecoration5] = createBlockDecorationAfterScreenRow(7, {className: "decoration-5"}) + let [item6, blockDecoration6] = createBlockDecorationAfterScreenRow(12, {className: "decoration-6"}) + + atom.styles.addStyleSheet( + `atom-text-editor .decoration-1 { width: 30px; height: 80px; } + atom-text-editor .decoration-2 { width: 30px; height: 40px; } + atom-text-editor .decoration-3 { width: 30px; height: 100px; } + atom-text-editor .decoration-4 { width: 30px; height: 120px; } + atom-text-editor .decoration-5 { width: 30px; height: 42px; } + atom-text-editor .decoration-6 { width: 30px; height: 22px; }`, + {context: 'atom-text-editor'} + ) + runAnimationFrames() + expect(component.getDomNode().querySelectorAll(".line").length).toBe(7) + expect(verticalScrollbarNode.scrollHeight).toBe(editor.getScreenLineCount() * editor.getLineHeightInPixels() + 80 + 40 + 100 + 120 + 42 + 22) + expect(component.tileNodesForLines()[0].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 80 + 40 + "px") + expect(component.tileNodesForLines()[0].style.webkitTransform).toBe("translate3d(0px, 0px, 0px)") + expect(component.tileNodesForLines()[1].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 100 + "px") + expect(component.tileNodesForLines()[1].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight}px, 0px)`) + expect(component.tileNodesForLines()[2].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 120 + 42 + "px") + expect(component.tileNodesForLines()[2].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight + component.tileNodesForLines()[1].offsetHeight}px, 0px)`) + expect(component.getTopmostDOMNode().querySelector(".decoration-1")).toBe(item1) + expect(component.getTopmostDOMNode().querySelector(".decoration-2")).toBe(item2) + expect(component.getTopmostDOMNode().querySelector(".decoration-3")).toBe(item3) + expect(component.getTopmostDOMNode().querySelector(".decoration-4")).toBeNull() + expect(component.getTopmostDOMNode().querySelector(".decoration-5")).toBeNull() + expect(component.getTopmostDOMNode().querySelector(".decoration-6")).toBeNull() + expect(item1.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 0) + expect(item2.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 2 + 80) + expect(item3.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 4 + 80 + 40) + + editor.setCursorScreenPosition([0, 0]) + editor.insertNewline() + blockDecoration1.destroy() + runAnimationFrames() + expect(component.getDomNode().querySelectorAll(".line").length).toBe(7) + expect(verticalScrollbarNode.scrollHeight).toBe(editor.getScreenLineCount() * editor.getLineHeightInPixels() + 40 + 100 + 120 + 42 + 22) + expect(component.tileNodesForLines()[0].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + "px") + expect(component.tileNodesForLines()[0].style.webkitTransform).toBe("translate3d(0px, 0px, 0px)") + expect(component.tileNodesForLines()[1].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 100 + 40 + "px") + expect(component.tileNodesForLines()[1].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight}px, 0px)`) + expect(component.tileNodesForLines()[2].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 120 + 42 + "px") + expect(component.tileNodesForLines()[2].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight + component.tileNodesForLines()[1].offsetHeight}px, 0px)`) + expect(component.getTopmostDOMNode().querySelector(".decoration-1")).toBeNull() + expect(component.getTopmostDOMNode().querySelector(".decoration-2")).toBe(item2) + expect(component.getTopmostDOMNode().querySelector(".decoration-3")).toBe(item3) + expect(component.getTopmostDOMNode().querySelector(".decoration-4")).toBeNull() + expect(component.getTopmostDOMNode().querySelector(".decoration-5")).toBeNull() + expect(component.getTopmostDOMNode().querySelector(".decoration-6")).toBeNull() + expect(item2.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 3) + expect(item3.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 5 + 40) + + atom.styles.addStyleSheet( + 'atom-text-editor .decoration-2 { height: 60px; }', + {context: 'atom-text-editor'} + ) + + runAnimationFrames() // causes the DOM to update and to retrieve new styles + runAnimationFrames() // applies the changes + expect(component.getDomNode().querySelectorAll(".line").length).toBe(7) + expect(verticalScrollbarNode.scrollHeight).toBe(editor.getScreenLineCount() * editor.getLineHeightInPixels() + 60 + 100 + 120 + 42 + 22) + expect(component.tileNodesForLines()[0].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + "px") + expect(component.tileNodesForLines()[0].style.webkitTransform).toBe("translate3d(0px, 0px, 0px)") + expect(component.tileNodesForLines()[1].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 100 + 60 + "px") + expect(component.tileNodesForLines()[1].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight}px, 0px)`) + expect(component.tileNodesForLines()[2].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 120 + 42 + "px") + expect(component.tileNodesForLines()[2].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight + component.tileNodesForLines()[1].offsetHeight}px, 0px)`) + expect(component.getTopmostDOMNode().querySelector(".decoration-1")).toBeNull() + expect(component.getTopmostDOMNode().querySelector(".decoration-2")).toBe(item2) + expect(component.getTopmostDOMNode().querySelector(".decoration-3")).toBe(item3) + expect(component.getTopmostDOMNode().querySelector(".decoration-4")).toBeNull() + expect(component.getTopmostDOMNode().querySelector(".decoration-5")).toBeNull() + expect(component.getTopmostDOMNode().querySelector(".decoration-6")).toBeNull() + expect(item2.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 3) + expect(item3.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 5 + 60) + + item2.style.height = "20px" + wrapperNode.invalidateBlockDecorationDimensions(blockDecoration2) + runAnimationFrames() // causes the DOM to update and to retrieve new styles + runAnimationFrames() // applies the changes + expect(component.getDomNode().querySelectorAll(".line").length).toBe(9) + expect(verticalScrollbarNode.scrollHeight).toBe(editor.getScreenLineCount() * editor.getLineHeightInPixels() + 20 + 100 + 120 + 42 + 22) + expect(component.tileNodesForLines()[0].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + "px") + expect(component.tileNodesForLines()[0].style.webkitTransform).toBe("translate3d(0px, 0px, 0px)") + expect(component.tileNodesForLines()[1].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 100 + 20 + "px") + expect(component.tileNodesForLines()[1].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight}px, 0px)`) + expect(component.tileNodesForLines()[2].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 120 + 42 + "px") + expect(component.tileNodesForLines()[2].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight + component.tileNodesForLines()[1].offsetHeight}px, 0px)`) + expect(component.getTopmostDOMNode().querySelector(".decoration-1")).toBeNull() + expect(component.getTopmostDOMNode().querySelector(".decoration-2")).toBe(item2) + expect(component.getTopmostDOMNode().querySelector(".decoration-3")).toBe(item3) + expect(component.getTopmostDOMNode().querySelector(".decoration-4")).toBe(item4) + expect(component.getTopmostDOMNode().querySelector(".decoration-5")).toBe(item5) + expect(component.getTopmostDOMNode().querySelector(".decoration-6")).toBeNull() + expect(item2.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 3) + expect(item3.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 5 + 20) + expect(item4.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 8 + 20 + 100) + expect(item5.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 8 + 20 + 100 + 120 + lineHeightInPixels) + + item6.style.height = "33px" + wrapperNode.invalidateBlockDecorationDimensions(blockDecoration6) + runAnimationFrames() // causes the DOM to update and to retrieve new styles + runAnimationFrames() // applies the changes + expect(component.getDomNode().querySelectorAll(".line").length).toBe(9) + expect(verticalScrollbarNode.scrollHeight).toBe(editor.getScreenLineCount() * editor.getLineHeightInPixels() + 20 + 100 + 120 + 42 + 33) + expect(component.tileNodesForLines()[0].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + "px") + expect(component.tileNodesForLines()[0].style.webkitTransform).toBe("translate3d(0px, 0px, 0px)") + expect(component.tileNodesForLines()[1].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 100 + 20 + "px") + expect(component.tileNodesForLines()[1].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight}px, 0px)`) + expect(component.tileNodesForLines()[2].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 120 + 42 + "px") + expect(component.tileNodesForLines()[2].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight + component.tileNodesForLines()[1].offsetHeight}px, 0px)`) + expect(component.getTopmostDOMNode().querySelector(".decoration-1")).toBeNull() + expect(component.getTopmostDOMNode().querySelector(".decoration-2")).toBe(item2) + expect(component.getTopmostDOMNode().querySelector(".decoration-3")).toBe(item3) + expect(component.getTopmostDOMNode().querySelector(".decoration-4")).toBe(item4) + expect(component.getTopmostDOMNode().querySelector(".decoration-5")).toBe(item5) + expect(component.getTopmostDOMNode().querySelector(".decoration-6")).toBeNull() + expect(item2.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 3) + expect(item3.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 5 + 20) + expect(item4.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 8 + 20 + 100) + expect(item5.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 8 + 20 + 100 + 120 + lineHeightInPixels) + }) + + it("correctly sets screen rows on block decoration and ruler nodes, both initially and when decorations move", function () { + let [item, blockDecoration] = createBlockDecorationBeforeScreenRow(0, {className: "decoration-1"}) + atom.styles.addStyleSheet( + 'atom-text-editor .decoration-1 { width: 30px; height: 80px; }', + {context: 'atom-text-editor'} + ) + + runAnimationFrames() + const line0 = component.lineNodeForScreenRow(0) + expect(item.previousSibling.dataset.screenRow).toBe("0") + expect(item.dataset.screenRow).toBe("0") + expect(item.nextSibling.dataset.screenRow).toBe("0") + expect(line0.previousSibling).toBe(item.nextSibling) + + editor.setCursorBufferPosition([0, 0]) + editor.insertNewline() + runAnimationFrames() + const line1 = component.lineNodeForScreenRow(1) + expect(item.previousSibling.dataset.screenRow).toBe("1") + expect(item.dataset.screenRow).toBe("1") + expect(item.nextSibling.dataset.screenRow).toBe("1") + expect(line1.previousSibling).toBe(item.nextSibling) + + editor.setCursorBufferPosition([0, 0]) + editor.insertNewline() + runAnimationFrames() + const line2 = component.lineNodeForScreenRow(2) + expect(item.previousSibling.dataset.screenRow).toBe("2") + expect(item.dataset.screenRow).toBe("2") + expect(item.nextSibling.dataset.screenRow).toBe("2") + expect(line2.previousSibling).toBe(item.nextSibling) + + blockDecoration.getMarker().setHeadBufferPosition([4, 0]) + runAnimationFrames() + const line4 = component.lineNodeForScreenRow(4) + expect(item.previousSibling.dataset.screenRow).toBe("4") + expect(item.dataset.screenRow).toBe("4") + expect(item.nextSibling.dataset.screenRow).toBe("4") + expect(line4.previousSibling).toBe(item.nextSibling) + }) + + it('measures block decorations taking into account both top and bottom margins of the element and its children', function () { + let [item, blockDecoration] = createBlockDecorationBeforeScreenRow(0, {className: "decoration-1"}) + let child = document.createElement("div") + child.style.height = "7px" + child.style.width = "30px" + child.style.marginBottom = "20px" + item.appendChild(child) + atom.styles.addStyleSheet( + 'atom-text-editor .decoration-1 { width: 30px; margin-top: 10px; }', + {context: 'atom-text-editor'} + ) + + runAnimationFrames() // causes the DOM to update and to retrieve new styles + runAnimationFrames() // applies the changes + + expect(component.tileNodesForLines()[0].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 10 + 7 + 20 + "px") + expect(component.tileNodesForLines()[0].style.webkitTransform).toBe("translate3d(0px, 0px, 0px)") + expect(component.tileNodesForLines()[1].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + "px") + expect(component.tileNodesForLines()[1].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight}px, 0px)`) + expect(component.tileNodesForLines()[2].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + "px") + expect(component.tileNodesForLines()[2].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight + component.tileNodesForLines()[1].offsetHeight}px, 0px)`) + }) + + it('allows the same block decoration item to be moved from one tile to another in the same animation frame', function () { + let [item, blockDecoration] = createBlockDecorationBeforeScreenRow(5, {className: "decoration-1"}) + runAnimationFrames() + expect(component.tileNodesForLines()[0].querySelector('.decoration-1')).toBeNull() + expect(component.tileNodesForLines()[1].querySelector('.decoration-1')).toBe(item) + + blockDecoration.getMarker().setHeadBufferPosition([0, 0]) + runAnimationFrames() + expect(component.tileNodesForLines()[0].querySelector('.decoration-1')).toBe(item) + expect(component.tileNodesForLines()[1].querySelector('.decoration-1')).toBeNull() + }) + }) + + describe('highlight decoration rendering', function () { + let decoration, marker, scrollViewClientLeft + + beforeEach(async function () { + scrollViewClientLeft = componentNode.querySelector('.scroll-view').getBoundingClientRect().left + marker = editor.addMarkerLayer({ + maintainHistory: true + }).markBufferRange([[2, 13], [3, 15]], { + invalidate: 'inside' + }) + decoration = editor.decorateMarker(marker, { + type: 'highlight', + 'class': 'test-highlight' + }) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + }) + + it('does not render highlights for off-screen lines until they come on-screen', async function () { + wrapperNode.style.height = 2.5 * lineHeightInPixels + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + marker = editor.markBufferRange([[9, 2], [9, 4]], { + invalidate: 'inside' + }) + editor.decorateMarker(marker, { + type: 'highlight', + 'class': 'some-highlight' + }) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + expect(component.presenter.endRow).toBeLessThan(9) + let regions = componentNode.querySelectorAll('.some-highlight .region') + expect(regions.length).toBe(0) + verticalScrollbarNode.scrollTop = 6 * lineHeightInPixels + verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) + runAnimationFrames(true) + + expect(component.presenter.endRow).toBeGreaterThan(8) + regions = componentNode.querySelectorAll('.some-highlight .region') + expect(regions.length).toBe(1) + let regionRect = regions[0].style + expect(regionRect.top).toBe(0 + 'px') + expect(regionRect.height).toBe(1 * lineHeightInPixels + 'px') + expect(regionRect.left).toBe(Math.round(2 * charWidth) + 'px') + expect(regionRect.width).toBe(Math.round(2 * charWidth) + 'px') + }) + + it('renders highlights decoration\'s marker is added', function () { + let regions = componentNode.querySelectorAll('.test-highlight .region') + expect(regions.length).toBe(2) + }) + + it('removes highlights when a decoration is removed', async function () { + decoration.destroy() + await decorationsUpdatedPromise(editor) + runAnimationFrames() + let regions = componentNode.querySelectorAll('.test-highlight .region') + expect(regions.length).toBe(0) + }) + + it('does not render a highlight that is within a fold', async function () { + editor.foldBufferRow(1) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + expect(componentNode.querySelectorAll('.test-highlight').length).toBe(0) + }) + + it('removes highlights when a decoration\'s marker is destroyed', async function () { + marker.destroy() + await decorationsUpdatedPromise(editor) + runAnimationFrames() + let regions = componentNode.querySelectorAll('.test-highlight .region') + expect(regions.length).toBe(0) + }) + + it('only renders highlights when a decoration\'s marker is valid', async function () { + editor.getBuffer().insert([3, 2], 'n') + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + expect(marker.isValid()).toBe(false) + let regions = componentNode.querySelectorAll('.test-highlight .region') + expect(regions.length).toBe(0) + editor.getBuffer().undo() + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + expect(marker.isValid()).toBe(true) + regions = componentNode.querySelectorAll('.test-highlight .region') + expect(regions.length).toBe(2) + }) + + it('allows multiple space-delimited decoration classes', async function () { + decoration.setProperties({ + type: 'highlight', + 'class': 'foo bar' + }) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + expect(componentNode.querySelectorAll('.foo.bar').length).toBe(2) + decoration.setProperties({ + type: 'highlight', + 'class': 'bar baz' + }) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + expect(componentNode.querySelectorAll('.bar.baz').length).toBe(2) + }) + + it('renders classes on the regions directly if "deprecatedRegionClass" option is defined', async function () { + decoration = editor.decorateMarker(marker, { + type: 'highlight', + 'class': 'test-highlight', + deprecatedRegionClass: 'test-highlight-region' + }) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + let regions = componentNode.querySelectorAll('.test-highlight .region.test-highlight-region') + expect(regions.length).toBe(2) + }) + + describe('when flashing a decoration via Decoration::flash()', function () { + let highlightNode + + beforeEach(function () { + highlightNode = componentNode.querySelectorAll('.test-highlight')[1] + }) + + it('adds and removes the flash class specified in ::flash', async function () { + expect(highlightNode.classList.contains('flash-class')).toBe(false) + decoration.flash('flash-class', 10) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + expect(highlightNode.classList.contains('flash-class')).toBe(true) + advanceClock(10) + expect(highlightNode.classList.contains('flash-class')).toBe(false) + }) + + describe('when ::flash is called again before the first has finished', function () { + it('removes the class from the decoration highlight before adding it for the second ::flash call', async function () { + decoration.flash('flash-class', 500) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + expect(highlightNode.classList.contains('flash-class')).toBe(true) + + decoration.flash('flash-class', 500) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + expect(highlightNode.classList.contains('flash-class')).toBe(false) + runAnimationFrames() + expect(highlightNode.classList.contains('flash-class')).toBe(true) + advanceClock(500) + expect(highlightNode.classList.contains('flash-class')).toBe(false) + }) + }) + }) + + describe('when a decoration\'s marker moves', function () { + it('moves rendered highlights when the buffer is changed', async function () { + let regionStyle = componentNode.querySelector('.test-highlight .region').style + let originalTop = parseInt(regionStyle.top) + expect(originalTop).toBe(2 * lineHeightInPixels) + + editor.getBuffer().insert([0, 0], '\n') + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + regionStyle = componentNode.querySelector('.test-highlight .region').style + let newTop = parseInt(regionStyle.top) + expect(newTop).toBe(0) + }) + + it('moves rendered highlights when the marker is manually moved', async function () { + let regionStyle = componentNode.querySelector('.test-highlight .region').style + expect(parseInt(regionStyle.top)).toBe(2 * lineHeightInPixels) + + marker.setBufferRange([[5, 8], [5, 13]]) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + regionStyle = componentNode.querySelector('.test-highlight .region').style + expect(parseInt(regionStyle.top)).toBe(2 * lineHeightInPixels) + }) + }) + + describe('when a decoration is updated via Decoration::update', function () { + it('renders the decoration\'s new params', async function () { + expect(componentNode.querySelector('.test-highlight')).toBeTruthy() + decoration.setProperties({ + type: 'highlight', + 'class': 'new-test-highlight' + }) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + expect(componentNode.querySelector('.test-highlight')).toBeFalsy() + expect(componentNode.querySelector('.new-test-highlight')).toBeTruthy() + }) + }) + }) + + describe('overlay decoration rendering', function () { + let gutterWidth, item + + beforeEach(function () { + item = document.createElement('div') + item.classList.add('overlay-test') + item.style.background = 'red' + gutterWidth = componentNode.querySelector('.gutter').offsetWidth + }) + + describe('when the marker is empty', function () { + it('renders an overlay decoration when added and removes the overlay when the decoration is destroyed', async function () { + let marker = editor.markBufferRange([[2, 13], [2, 13]], { + invalidate: 'never' + }) + let decoration = editor.decorateMarker(marker, { + type: 'overlay', + item: item + }) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + let overlay = component.getTopmostDOMNode().querySelector('atom-overlay .overlay-test') + expect(overlay).toBe(item) + + decoration.destroy() + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + overlay = component.getTopmostDOMNode().querySelector('atom-overlay .overlay-test') + expect(overlay).toBe(null) + }) + + it('renders the overlay element with the CSS class specified by the decoration', async function () { + let marker = editor.markBufferRange([[2, 13], [2, 13]], { + invalidate: 'never' + }) + let decoration = editor.decorateMarker(marker, { + type: 'overlay', + 'class': 'my-overlay', + item: item + }) + + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + let overlay = component.getTopmostDOMNode().querySelector('atom-overlay.my-overlay') + expect(overlay).not.toBe(null) + let child = overlay.querySelector('.overlay-test') + expect(child).toBe(item) + }) + }) + + describe('when the marker is not empty', function () { + it('renders at the head of the marker by default', async function () { + let marker = editor.markBufferRange([[2, 5], [2, 10]], { + invalidate: 'never' + }) + let decoration = editor.decorateMarker(marker, { + type: 'overlay', + item: item + }) + + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + let position = wrapperNode.pixelPositionForBufferPosition([2, 10]) + let overlay = component.getTopmostDOMNode().querySelector('atom-overlay') + expect(overlay.style.left).toBe(Math.round(position.left + gutterWidth) + 'px') + expect(overlay.style.top).toBe(position.top + editor.getLineHeightInPixels() + 'px') + }) + }) + + describe('positioning the overlay when near the edge of the editor', function () { + let itemHeight, itemWidth, windowHeight, windowWidth + + beforeEach(async function () { + atom.storeWindowDimensions() + itemWidth = Math.round(4 * editor.getDefaultCharWidth()) + itemHeight = 4 * editor.getLineHeightInPixels() + windowWidth = Math.round(gutterWidth + 30 * editor.getDefaultCharWidth()) + windowHeight = 10 * editor.getLineHeightInPixels() + item.style.width = itemWidth + 'px' + item.style.height = itemHeight + 'px' + wrapperNode.style.width = windowWidth + 'px' + wrapperNode.style.height = windowHeight + 'px' + editor.update({autoHeight: false}) + await atom.setWindowDimensions({ + width: windowWidth, + height: windowHeight + }) + + component.measureDimensions() + component.measureWindowSize() + runAnimationFrames() + }) + + afterEach(function () { + atom.restoreWindowDimensions() + }) + + it('slides horizontally left when near the right edge on #win32 and #darwin', async function () { + let marker = editor.markBufferRange([[0, 26], [0, 26]], { + invalidate: 'never' + }) + let decoration = editor.decorateMarker(marker, { + type: 'overlay', + item: item + }) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + let position = wrapperNode.pixelPositionForBufferPosition([0, 26]) + let overlay = component.getTopmostDOMNode().querySelector('atom-overlay') + if (process.platform == 'darwin') { // Result is 359px on win32, expects 375px + expect(overlay.style.left).toBe(Math.round(position.left + gutterWidth) + 'px') + } + expect(overlay.style.top).toBe(position.top + editor.getLineHeightInPixels() + 'px') + + editor.insertText('a') + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + expect(overlay.style.left).toBe(window.innerWidth - itemWidth + 'px') + expect(overlay.style.top).toBe(position.top + editor.getLineHeightInPixels() + 'px') + + editor.insertText('b') + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + expect(overlay.style.left).toBe(window.innerWidth - itemWidth + 'px') + expect(overlay.style.top).toBe(position.top + editor.getLineHeightInPixels() + 'px') + + // window size change + const innerWidthBefore = window.innerWidth + await atom.setWindowDimensions({ + width: Math.round(gutterWidth + 20 * editor.getDefaultCharWidth()), + height: windowHeight, + }) + // wait for window to resize :( + await conditionPromise(() => { + return window.innerWidth !== innerWidthBefore + }) + + runAnimationFrames() + + expect(overlay.style.left).toBe(window.innerWidth - itemWidth + 'px') + expect(overlay.style.top).toBe(position.top + editor.getLineHeightInPixels() + 'px') + }) + }) + }) + + describe('hidden input field', function () { + it('renders the hidden input field at the position of the last cursor if the cursor is on screen and the editor is focused', async function () { + editor.setVerticalScrollMargin(0) + editor.setHorizontalScrollMargin(0) + let inputNode = componentNode.querySelector('.hidden-input') + wrapperNode.style.height = 5 * lineHeightInPixels + 'px' + wrapperNode.style.width = 10 * charWidth + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + + wrapperNode.setScrollTop(3 * lineHeightInPixels) + wrapperNode.setScrollLeft(3 * charWidth) + runAnimationFrames() + + expect(inputNode.offsetTop).toBe(0) + expect(inputNode.offsetLeft).toBe(0) + + editor.setCursorBufferPosition([5, 4], { + autoscroll: false + }) + await decorationsUpdatedPromise(editor) + runAnimationFrames() + + expect(inputNode.offsetTop).toBe(0) + expect(inputNode.offsetLeft).toBe(0) + + wrapperNode.focus() + runAnimationFrames() + + expect(inputNode.offsetTop).toBe((5 * lineHeightInPixels) - wrapperNode.getScrollTop()) + expect(inputNode.offsetLeft).toBeCloseTo((4 * charWidth) - wrapperNode.getScrollLeft(), 0) + + inputNode.blur() + runAnimationFrames() + + expect(inputNode.offsetTop).toBe(0) + expect(inputNode.offsetLeft).toBe(0) + + editor.setCursorBufferPosition([1, 2], { + autoscroll: false + }) + runAnimationFrames() + + expect(inputNode.offsetTop).toBe(0) + expect(inputNode.offsetLeft).toBe(0) + + inputNode.focus() + runAnimationFrames() + + expect(inputNode.offsetTop).toBe(0) + expect(inputNode.offsetLeft).toBe(0) + }) + }) + + describe('mouse interactions on the lines', function () { + let linesNode + + beforeEach(function () { + linesNode = componentNode.querySelector('.lines') + }) + + describe('when the mouse is single-clicked above the first line', function () { + it('moves the cursor to the start of file buffer position', function () { + let height + editor.setText('foo') + editor.setCursorBufferPosition([0, 3]) + height = 4.5 * lineHeightInPixels + wrapperNode.style.height = height + 'px' + wrapperNode.style.width = 10 * charWidth + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + let coordinates = clientCoordinatesForScreenPosition([0, 2]) + coordinates.clientY = -1 + linesNode.dispatchEvent(buildMouseEvent('mousedown', coordinates)) + + runAnimationFrames() + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + }) + }) + + describe('when the mouse is single-clicked below the last line', function () { + it('moves the cursor to the end of file buffer position', function () { + editor.setText('foo') + editor.setCursorBufferPosition([0, 0]) + let height = 4.5 * lineHeightInPixels + wrapperNode.style.height = height + 'px' + wrapperNode.style.width = 10 * charWidth + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + let coordinates = clientCoordinatesForScreenPosition([0, 2]) + coordinates.clientY = height * 2 + + linesNode.dispatchEvent(buildMouseEvent('mousedown', coordinates)) + runAnimationFrames() + + expect(editor.getCursorScreenPosition()).toEqual([0, 3]) + }) + }) + + describe('when a non-folded line is single-clicked', function () { + describe('when no modifier keys are held down', function () { + it('moves the cursor to the nearest screen position', function () { + wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' + wrapperNode.style.width = 10 * charWidth + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + wrapperNode.setScrollTop(3.5 * lineHeightInPixels) + wrapperNode.setScrollLeft(2 * charWidth) + runAnimationFrames() + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([4, 8]))) + runAnimationFrames() + expect(editor.getCursorScreenPosition()).toEqual([4, 8]) + }) + }) + + describe('when the shift key is held down', function () { + it('selects to the nearest screen position', function () { + editor.setCursorScreenPosition([3, 4]) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 6]), { + shiftKey: true + })) + runAnimationFrames() + expect(editor.getSelectedScreenRange()).toEqual([[3, 4], [5, 6]]) + }) + }) + + describe('when the command key is held down', function () { + describe('the current cursor position and screen position do not match', function () { + it('adds a cursor at the nearest screen position', function () { + editor.setCursorScreenPosition([3, 4]) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 6]), { + metaKey: true + })) + runAnimationFrames() + expect(editor.getSelectedScreenRanges()).toEqual([[[3, 4], [3, 4]], [[5, 6], [5, 6]]]) + }) + }) + + describe('when there are multiple cursors, and one of the cursor\'s screen position is the same as the mouse click screen position', function () { + it('removes a cursor at the mouse screen position', function () { + editor.setCursorScreenPosition([3, 4]) + editor.addCursorAtScreenPosition([5, 2]) + editor.addCursorAtScreenPosition([7, 5]) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([3, 4]), { + metaKey: true + })) + runAnimationFrames() + expect(editor.getSelectedScreenRanges()).toEqual([[[5, 2], [5, 2]], [[7, 5], [7, 5]]]) + }) + }) + + describe('when there is a single cursor and the click occurs at the cursor\'s screen position', function () { + it('neither adds a new cursor nor removes the current cursor', function () { + editor.setCursorScreenPosition([3, 4]) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([3, 4]), { + metaKey: true + })) + runAnimationFrames() + expect(editor.getSelectedScreenRanges()).toEqual([[[3, 4], [3, 4]]]) + }) + }) + }) + }) + + describe('when a non-folded line is double-clicked', function () { + describe('when no modifier keys are held down', function () { + it('selects the word containing the nearest screen position', function () { + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { + detail: 1 + })) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { + detail: 2 + })) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + expect(editor.getSelectedScreenRange()).toEqual([[5, 6], [5, 13]]) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([6, 6]), { + detail: 1 + })) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + expect(editor.getSelectedScreenRange()).toEqual([[6, 6], [6, 6]]) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([8, 8]), { + detail: 1, + shiftKey: true + })) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + expect(editor.getSelectedScreenRange()).toEqual([[6, 6], [8, 8]]) + }) + }) + + describe('when the command key is held down', function () { + it('selects the word containing the newly-added cursor', function () { + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { + detail: 1, + metaKey: true + })) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { + detail: 2, + metaKey: true + })) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + expect(editor.getSelectedScreenRanges()).toEqual([[[0, 0], [0, 0]], [[5, 6], [5, 13]]]) + }) + }) + }) + + describe('when a non-folded line is triple-clicked', function () { + describe('when no modifier keys are held down', function () { + it('selects the line containing the nearest screen position', function () { + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { + detail: 1 + })) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { + detail: 2 + })) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { + detail: 3 + })) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [6, 0]]) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([6, 6]), { + detail: 1, + shiftKey: true + })) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [7, 0]]) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([7, 5]), { + detail: 1 + })) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([8, 8]), { + detail: 1, + shiftKey: true + })) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + expect(editor.getSelectedScreenRange()).toEqual([[7, 5], [8, 8]]) + }) + }) + + describe('when the command key is held down', function () { + it('selects the line containing the newly-added cursor', function () { + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { + detail: 1, + metaKey: true + })) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { + detail: 2, + metaKey: true + })) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { + detail: 3, + metaKey: true + })) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + expect(editor.getSelectedScreenRanges()).toEqual([[[0, 0], [0, 0]], [[5, 0], [6, 0]]]) + }) + }) + }) + + describe('when the mouse is clicked and dragged', function () { + it('selects to the nearest screen position until the mouse button is released', function () { + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), { + which: 1 + })) + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), { + which: 1 + })) + runAnimationFrames() + expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [6, 8]]) + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([10, 0]), { + which: 1 + })) + runAnimationFrames() + expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [10, 0]]) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([12, 0]), { + which: 1 + })) + runAnimationFrames() + expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [10, 0]]) + }) + + it('autoscrolls when the cursor approaches the boundaries of the editor', function () { + wrapperNode.style.height = '100px' + wrapperNode.style.width = '100px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(0) + expect(wrapperNode.getScrollLeft()).toBe(0) + + linesNode.dispatchEvent(buildMouseEvent('mousedown', { + clientX: 0, + clientY: 0 + }, { + which: 1 + })) + linesNode.dispatchEvent(buildMouseEvent('mousemove', { + clientX: 100, + clientY: 50 + }, { + which: 1 + })) + + for (let i = 0; i <= 5; ++i) { + runAnimationFrames() + } + + expect(wrapperNode.getScrollTop()).toBe(0) + expect(wrapperNode.getScrollLeft()).toBeGreaterThan(0) + linesNode.dispatchEvent(buildMouseEvent('mousemove', { + clientX: 100, + clientY: 100 + }, { + which: 1 + })) + + for (let i = 0; i <= 5; ++i) { + runAnimationFrames() + } + + expect(wrapperNode.getScrollTop()).toBeGreaterThan(0) + let previousScrollTop = wrapperNode.getScrollTop() + let previousScrollLeft = wrapperNode.getScrollLeft() + + linesNode.dispatchEvent(buildMouseEvent('mousemove', { + clientX: 10, + clientY: 50 + }, { + which: 1 + })) + + for (let i = 0; i <= 5; ++i) { + runAnimationFrames() + } + + expect(wrapperNode.getScrollTop()).toBe(previousScrollTop) + expect(wrapperNode.getScrollLeft()).toBeLessThan(previousScrollLeft) + linesNode.dispatchEvent(buildMouseEvent('mousemove', { + clientX: 10, + clientY: 10 + }, { + which: 1 + })) + + for (let i = 0; i <= 5; ++i) { + runAnimationFrames() + } + + expect(wrapperNode.getScrollTop()).toBeLessThan(previousScrollTop) + }) + + it('stops selecting if the mouse is dragged into the dev tools', function () { + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), { + which: 1 + })) + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), { + which: 1 + })) + runAnimationFrames() + expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [6, 8]]) + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([10, 0]), { + which: 0 + })) + runAnimationFrames() + expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [6, 8]]) + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([8, 0]), { + which: 1 + })) + runAnimationFrames() + expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [6, 8]]) + }) + + it('stops selecting before the buffer is modified during the drag', function () { + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), { + which: 1 + })) + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), { + which: 1 + })) + runAnimationFrames() + + expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [6, 8]]) + + editor.insertText('x') + runAnimationFrames() + + expect(editor.getSelectedScreenRange()).toEqual([[2, 5], [2, 5]]) + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([8, 0]), { + which: 1 + })) + expect(editor.getSelectedScreenRange()).toEqual([[2, 5], [2, 5]]) + + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), { + which: 1 + })) + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([5, 4]), { + which: 1 + })) + runAnimationFrames() + + expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [5, 4]]) + + editor.delete() + runAnimationFrames() + + expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [2, 4]]) + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([8, 0]), { + which: 1 + })) + expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [2, 4]]) + }) + + describe('when the command key is held down', function () { + it('adds a new selection and selects to the nearest screen position, then merges intersecting selections when the mouse button is released', function () { + editor.setSelectedScreenRange([[4, 4], [4, 9]]) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), { + which: 1, + metaKey: true + })) + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), { + which: 1 + })) + runAnimationFrames() + + expect(editor.getSelectedScreenRanges()).toEqual([[[4, 4], [4, 9]], [[2, 4], [6, 8]]]) + + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([4, 6]), { + which: 1 + })) + runAnimationFrames() + + expect(editor.getSelectedScreenRanges()).toEqual([[[4, 4], [4, 9]], [[2, 4], [4, 6]]]) + linesNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenPosition([4, 6]), { + which: 1 + })) + expect(editor.getSelectedScreenRanges()).toEqual([[[2, 4], [4, 9]]]) + }) + }) + + describe('when the editor is destroyed while dragging', function () { + it('cleans up the handlers for window.mouseup and window.mousemove', function () { + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), { + which: 1 + })) + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), { + which: 1 + })) + runAnimationFrames() + + spyOn(window, 'removeEventListener').andCallThrough() + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 10]), { + which: 1 + })) + + editor.destroy() + runAnimationFrames() + + for (let call of window.removeEventListener.calls) { + call.args.pop() + } + expect(window.removeEventListener).toHaveBeenCalledWith('mouseup') + expect(window.removeEventListener).toHaveBeenCalledWith('mousemove') + }) + }) + }) + + describe('when the mouse is double-clicked and dragged', function () { + it('expands the selection over the nearest word as the cursor moves', function () { + jasmine.attachToDOM(wrapperNode) + wrapperNode.style.height = 6 * lineHeightInPixels + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { + detail: 1 + })) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { + detail: 2 + })) + expect(editor.getSelectedScreenRange()).toEqual([[5, 6], [5, 13]]) + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([11, 11]), { + which: 1 + })) + runAnimationFrames() + + expect(editor.getSelectedScreenRange()).toEqual([[5, 6], [12, 2]]) + let maximalScrollTop = wrapperNode.getScrollTop() + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([9, 3]), { + which: 1 + })) + runAnimationFrames() + + expect(editor.getSelectedScreenRange()).toEqual([[5, 6], [9, 4]]) + expect(wrapperNode.getScrollTop()).toBe(maximalScrollTop) + linesNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenPosition([9, 3]), { + which: 1 + })) + }) + }) + + describe('when the mouse is triple-clicked and dragged', function () { + it('expands the selection over the nearest line as the cursor moves', function () { + jasmine.attachToDOM(wrapperNode) + wrapperNode.style.height = 6 * lineHeightInPixels + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { + detail: 1 + })) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { + detail: 2 + })) + linesNode.dispatchEvent(buildMouseEvent('mouseup')) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { + detail: 3 + })) + expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [6, 0]]) + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([11, 11]), { + which: 1 + })) + runAnimationFrames() + + expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [12, 2]]) + let maximalScrollTop = wrapperNode.getScrollTop() + linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([8, 4]), { + which: 1 + })) + runAnimationFrames() + + expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [8, 0]]) + expect(wrapperNode.getScrollTop()).toBe(maximalScrollTop) + linesNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenPosition([9, 3]), { + which: 1 + })) + }) + }) + + describe('when a fold marker is clicked', function () { + function clickElementAtPosition (marker, position) { + linesNode.dispatchEvent( + buildMouseEvent('mousedown', clientCoordinatesForScreenPosition(position), {target: marker}) + ) + } + + it('unfolds only the selected fold when other folds are on the same line', function () { + editor.foldBufferRange([[4, 6], [4, 10]]) + editor.foldBufferRange([[4, 15], [4, 20]]) + runAnimationFrames() + + let foldMarkers = component.lineNodeForScreenRow(4).querySelectorAll('.fold-marker') + expect(foldMarkers.length).toBe(2) + expect(editor.isFoldedAtBufferRow(4)).toBe(true) + + clickElementAtPosition(foldMarkers[0], [4, 6]) + runAnimationFrames() + foldMarkers = component.lineNodeForScreenRow(4).querySelectorAll('.fold-marker') + expect(foldMarkers.length).toBe(1) + expect(editor.isFoldedAtBufferRow(4)).toBe(true) + + clickElementAtPosition(foldMarkers[0], [4, 15]) + runAnimationFrames() + foldMarkers = component.lineNodeForScreenRow(4).querySelectorAll('.fold-marker') + expect(foldMarkers.length).toBe(0) + expect(editor.isFoldedAtBufferRow(4)).toBe(false) + }) + + it('unfolds only the selected fold when other folds are inside it', function () { + editor.foldBufferRange([[4, 10], [4, 15]]) + editor.foldBufferRange([[4, 4], [4, 5]]) + editor.foldBufferRange([[4, 4], [4, 20]]) + runAnimationFrames() + let foldMarkers = component.lineNodeForScreenRow(4).querySelectorAll('.fold-marker') + expect(foldMarkers.length).toBe(1) + expect(editor.isFoldedAtBufferRow(4)).toBe(true) + + clickElementAtPosition(foldMarkers[0], [4, 4]) + runAnimationFrames() + foldMarkers = component.lineNodeForScreenRow(4).querySelectorAll('.fold-marker') + expect(foldMarkers.length).toBe(1) + expect(editor.isFoldedAtBufferRow(4)).toBe(true) + + clickElementAtPosition(foldMarkers[0], [4, 4]) + runAnimationFrames() + foldMarkers = component.lineNodeForScreenRow(4).querySelectorAll('.fold-marker') + expect(foldMarkers.length).toBe(1) + expect(editor.isFoldedAtBufferRow(4)).toBe(true) + + clickElementAtPosition(foldMarkers[0], [4, 10]) + runAnimationFrames() + foldMarkers = component.lineNodeForScreenRow(4).querySelectorAll('.fold-marker') + expect(foldMarkers.length).toBe(0) + expect(editor.isFoldedAtBufferRow(4)).toBe(false) + }) + }) + + describe('when the horizontal scrollbar is interacted with', function () { + it('clicking on the scrollbar does not move the cursor', function () { + let target = horizontalScrollbarNode + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([4, 8]), { + target: target + })) + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + }) + }) + }) + + describe('mouse interactions on the gutter', function () { + let gutterNode + + beforeEach(function () { + gutterNode = componentNode.querySelector('.gutter') + }) + + describe('when the component is destroyed', function () { + it('stops listening for selection events', function () { + component.destroy() + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1))) + expect(editor.getSelectedScreenRange()).toEqual([[0, 0], [0, 0]]) + }) + }) + + describe('when the gutter is clicked', function () { + it('selects the clicked row', function () { + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(4))) + expect(editor.getSelectedScreenRange()).toEqual([[4, 0], [5, 0]]) + }) + }) + + describe('when the gutter is meta-clicked', function () { + it('creates a new selection for the clicked row', function () { + editor.setSelectedScreenRange([[3, 0], [3, 2]]) + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(4), { + metaKey: true + })) + expect(editor.getSelectedScreenRanges()).toEqual([[[3, 0], [3, 2]], [[4, 0], [5, 0]]]) + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6), { + metaKey: true + })) + expect(editor.getSelectedScreenRanges()).toEqual([[[3, 0], [3, 2]], [[4, 0], [5, 0]], [[6, 0], [7, 0]]]) + }) + }) + + describe('when the gutter is shift-clicked', function () { + beforeEach(function () { + editor.setSelectedScreenRange([[3, 4], [4, 5]]) + }) + + describe('when the clicked row is before the current selection\'s tail', function () { + it('selects to the beginning of the clicked row', function () { + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), { + shiftKey: true + })) + expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [3, 4]]) + }) + }) + + describe('when the clicked row is after the current selection\'s tail', function () { + it('selects to the beginning of the row following the clicked row', function () { + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6), { + shiftKey: true + })) + expect(editor.getSelectedScreenRange()).toEqual([[3, 4], [7, 0]]) + }) + }) + }) + + describe('when the gutter is clicked and dragged', function () { + describe('when dragging downward', function () { + it('selects the rows between the start and end of the drag', function () { + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2))) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6))) + runAnimationFrames() + gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(6))) + expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [7, 0]]) + }) + }) + + describe('when dragging upward', function () { + it('selects the rows between the start and end of the drag', function () { + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6))) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(2))) + runAnimationFrames() + gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(2))) + expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [7, 0]]) + }) + }) + + it('orients the selection appropriately when the mouse moves above or below the initially-clicked row', function () { + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(4))) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(2))) + runAnimationFrames() + expect(editor.getLastSelection().isReversed()).toBe(true) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6))) + runAnimationFrames() + expect(editor.getLastSelection().isReversed()).toBe(false) + }) + + it('autoscrolls when the cursor approaches the top or bottom of the editor', function () { + wrapperNode.style.height = 6 * lineHeightInPixels + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(0) + + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2))) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(8))) + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBeGreaterThan(0) + let maxScrollTop = wrapperNode.getScrollTop() + + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(10))) + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(maxScrollTop) + + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(7))) + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBeLessThan(maxScrollTop) + }) + + it('stops selecting if a textInput event occurs during the drag', function () { + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2))) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6))) + runAnimationFrames() + + expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [7, 0]]) + + let inputEvent = new Event('textInput') + inputEvent.data = 'x' + Object.defineProperty(inputEvent, 'target', { + get: function () { + return componentNode.querySelector('.hidden-input') + } + }) + componentNode.dispatchEvent(inputEvent) + runAnimationFrames() + + expect(editor.getSelectedScreenRange()).toEqual([[2, 1], [2, 1]]) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(12))) + expect(editor.getSelectedScreenRange()).toEqual([[2, 1], [2, 1]]) + }) + }) + + describe('when the gutter is meta-clicked and dragged', function () { + beforeEach(function () { + editor.setSelectedScreenRange([[3, 0], [3, 2]]) + }) + + describe('when dragging downward', function () { + it('selects the rows between the start and end of the drag', function () { + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(4), { + metaKey: true + })) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6), { + metaKey: true + })) + runAnimationFrames() + + gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(6), { + metaKey: true + })) + expect(editor.getSelectedScreenRanges()).toEqual([[[3, 0], [3, 2]], [[4, 0], [7, 0]]]) + }) + + it('merges overlapping selections when the mouse button is released', function () { + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2), { + metaKey: true + })) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6), { + metaKey: true + })) + runAnimationFrames() + + expect(editor.getSelectedScreenRanges()).toEqual([[[3, 0], [3, 2]], [[2, 0], [7, 0]]]) + gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(6), { + metaKey: true + })) + expect(editor.getSelectedScreenRanges()).toEqual([[[2, 0], [7, 0]]]) + }) + }) + + describe('when dragging upward', function () { + it('selects the rows between the start and end of the drag', function () { + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6), { + metaKey: true + })) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(4), { + metaKey: true + })) + runAnimationFrames() + + gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(4), { + metaKey: true + })) + expect(editor.getSelectedScreenRanges()).toEqual([[[3, 0], [3, 2]], [[4, 0], [7, 0]]]) + }) + + it('merges overlapping selections', function () { + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6), { + metaKey: true + })) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(2), { + metaKey: true + })) + runAnimationFrames() + + gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(2), { + metaKey: true + })) + expect(editor.getSelectedScreenRanges()).toEqual([[[2, 0], [7, 0]]]) + }) + }) + }) + + describe('when the gutter is shift-clicked and dragged', function () { + describe('when the shift-click is below the existing selection\'s tail', function () { + describe('when dragging downward', function () { + it('selects the rows between the existing selection\'s tail and the end of the drag', function () { + editor.setSelectedScreenRange([[3, 4], [4, 5]]) + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(7), { + shiftKey: true + })) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(8))) + runAnimationFrames() + expect(editor.getSelectedScreenRange()).toEqual([[3, 4], [9, 0]]) + }) + }) + + describe('when dragging upward', function () { + it('selects the rows between the end of the drag and the tail of the existing selection', function () { + editor.setSelectedScreenRange([[4, 4], [5, 5]]) + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(7), { + shiftKey: true + })) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(5))) + runAnimationFrames() + expect(editor.getSelectedScreenRange()).toEqual([[4, 4], [6, 0]]) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(1))) + runAnimationFrames() + expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [4, 4]]) + }) + }) + }) + + describe('when the shift-click is above the existing selection\'s tail', function () { + describe('when dragging upward', function () { + it('selects the rows between the end of the drag and the tail of the existing selection', function () { + editor.setSelectedScreenRange([[4, 4], [5, 5]]) + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2), { + shiftKey: true + })) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(1))) + runAnimationFrames() + expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [4, 4]]) + }) + }) + + describe('when dragging downward', function () { + it('selects the rows between the existing selection\'s tail and the end of the drag', function () { + editor.setSelectedScreenRange([[3, 4], [4, 5]]) + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), { + shiftKey: true + })) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(2))) + runAnimationFrames() + expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [3, 4]]) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(8))) + runAnimationFrames() + expect(editor.getSelectedScreenRange()).toEqual([[3, 4], [9, 0]]) + }) + }) + }) + }) + + describe('when soft wrap is enabled', function () { + beforeEach(function () { + gutterNode = componentNode.querySelector('.gutter') + editor.setSoftWrapped(true) + runAnimationFrames() + componentNode.style.width = 21 * charWidth + wrapperNode.getVerticalScrollbarWidth() + 'px' + component.measureDimensions() + runAnimationFrames() + }) + + describe('when the gutter is clicked', function () { + it('selects the clicked buffer row', function () { + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1))) + expect(editor.getSelectedScreenRange()).toEqual([[0, 0], [2, 0]]) + }) + }) + + describe('when the gutter is meta-clicked', function () { + it('creates a new selection for the clicked buffer row', function () { + editor.setSelectedScreenRange([[1, 0], [1, 2]]) + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2), { + metaKey: true + })) + expect(editor.getSelectedScreenRanges()).toEqual([[[1, 0], [1, 2]], [[2, 0], [5, 0]]]) + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(7), { + metaKey: true + })) + expect(editor.getSelectedScreenRanges()).toEqual([[[1, 0], [1, 2]], [[2, 0], [5, 0]], [[5, 0], [10, 0]]]) + }) + }) + + describe('when the gutter is shift-clicked', function () { + beforeEach(function () { + return editor.setSelectedScreenRange([[7, 4], [7, 6]]) + }) + + describe('when the clicked row is before the current selection\'s tail', function () { + it('selects to the beginning of the clicked buffer row', function () { + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), { + shiftKey: true + })) + expect(editor.getSelectedScreenRange()).toEqual([[0, 0], [7, 4]]) + }) + }) + + describe('when the clicked row is after the current selection\'s tail', function () { + it('selects to the beginning of the screen row following the clicked buffer row', function () { + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(11), { + shiftKey: true + })) + expect(editor.getSelectedScreenRange()).toEqual([[7, 4], [17, 0]]) + }) + }) + }) + + describe('when the gutter is clicked and dragged', function () { + describe('when dragging downward', function () { + it('selects the buffer row containing the click, then screen rows until the end of the drag', function () { + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1))) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6))) + runAnimationFrames() + gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(6))) + expect(editor.getSelectedScreenRange()).toEqual([[0, 0], [6, 14]]) + }) + }) + + describe('when dragging upward', function () { + it('selects the buffer row containing the click, then screen rows until the end of the drag', function () { + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6))) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(1))) + runAnimationFrames() + gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(1))) + expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [10, 0]]) + }) + }) + }) + + describe('when the gutter is meta-clicked and dragged', function () { + beforeEach(function () { + editor.setSelectedScreenRange([[7, 4], [7, 6]]) + }) + + describe('when dragging downward', function () { + it('adds a selection from the buffer row containing the click to the screen row containing the end of the drag', function () { + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), { + metaKey: true + })) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(3), { + metaKey: true + })) + runAnimationFrames() + gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(3), { + metaKey: true + })) + expect(editor.getSelectedScreenRanges()).toEqual([[[7, 4], [7, 6]], [[0, 0], [3, 14]]]) + }) + + it('merges overlapping selections on mouseup', function () { + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), { + metaKey: true + })) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(7), { + metaKey: true + })) + runAnimationFrames() + gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(7), { + metaKey: true + })) + expect(editor.getSelectedScreenRanges()).toEqual([[[0, 0], [7, 12]]]) + }) + }) + + describe('when dragging upward', function () { + it('adds a selection from the buffer row containing the click to the screen row containing the end of the drag', function () { + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(17), { + metaKey: true + })) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(11), { + metaKey: true + })) + runAnimationFrames() + gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(11), { + metaKey: true + })) + expect(editor.getSelectedScreenRanges()).toEqual([[[7, 4], [7, 6]], [[11, 4], [20, 0]]]) + }) + + it('merges overlapping selections on mouseup', function () { + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(17), { + metaKey: true + })) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(5), { + metaKey: true + })) + runAnimationFrames() + gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(5), { + metaKey: true + })) + expect(editor.getSelectedScreenRanges()).toEqual([[[5, 0], [20, 0]]]) + }) + }) + }) + + describe('when the gutter is shift-clicked and dragged', function () { + describe('when the shift-click is below the existing selection\'s tail', function () { + describe('when dragging downward', function () { + it('selects the screen rows between the existing selection\'s tail and the end of the drag', function () { + editor.setSelectedScreenRange([[1, 4], [1, 7]]) + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(7), { + shiftKey: true + })) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(11))) + runAnimationFrames() + expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [11, 5]]) + }) + }) + + describe('when dragging upward', function () { + it('selects the screen rows between the end of the drag and the tail of the existing selection', function () { + editor.setSelectedScreenRange([[1, 4], [1, 7]]) + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(11), { + shiftKey: true + })) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(7))) + runAnimationFrames() + expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [7, 12]]) + }) + }) + }) + + describe('when the shift-click is above the existing selection\'s tail', function () { + describe('when dragging upward', function () { + it('selects the screen rows between the end of the drag and the tail of the existing selection', function () { + editor.setSelectedScreenRange([[7, 4], [7, 6]]) + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(3), { + shiftKey: true + })) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(1))) + runAnimationFrames() + expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [7, 4]]) + }) + }) + + describe('when dragging downward', function () { + it('selects the screen rows between the existing selection\'s tail and the end of the drag', function () { + editor.setSelectedScreenRange([[7, 4], [7, 6]]) + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), { + shiftKey: true + })) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(3))) + runAnimationFrames() + expect(editor.getSelectedScreenRange()).toEqual([[3, 2], [7, 4]]) + }) + }) + }) + }) + }) + }) + + describe('focus handling', function () { + let inputNode + beforeEach(function () { + inputNode = componentNode.querySelector('.hidden-input') + }) + + it('transfers focus to the hidden input', function () { + expect(document.activeElement).toBe(document.body) + wrapperNode.focus() + expect(document.activeElement).toBe(inputNode) + }) + + it('adds the "is-focused" class to the editor when the hidden input is focused', function () { + expect(document.activeElement).toBe(document.body) + inputNode.focus() + runAnimationFrames() + + expect(componentNode.classList.contains('is-focused')).toBe(true) + expect(wrapperNode.classList.contains('is-focused')).toBe(true) + inputNode.blur() + runAnimationFrames() + + expect(componentNode.classList.contains('is-focused')).toBe(false) + expect(wrapperNode.classList.contains('is-focused')).toBe(false) + }) + }) + + describe('selection handling', function () { + let cursor + + beforeEach(function () { + editor.setCursorScreenPosition([0, 0]) + runAnimationFrames() + }) + + it('adds the "has-selection" class to the editor when there is a selection', function () { + expect(componentNode.classList.contains('has-selection')).toBe(false) + editor.selectDown() + runAnimationFrames() + expect(componentNode.classList.contains('has-selection')).toBe(true) + editor.moveDown() + runAnimationFrames() + expect(componentNode.classList.contains('has-selection')).toBe(false) + }) + }) + + describe('scrolling', function () { + it('updates the vertical scrollbar when the scrollTop is changed in the model', function () { + wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + expect(verticalScrollbarNode.scrollTop).toBe(0) + wrapperNode.setScrollTop(10) + runAnimationFrames() + expect(verticalScrollbarNode.scrollTop).toBe(10) + }) + + it('updates the horizontal scrollbar and the x transform of the lines based on the scrollLeft of the model', function () { + componentNode.style.width = 30 * charWidth + 'px' + component.measureDimensions() + runAnimationFrames() + + let top = 0 + let tilesNodes = component.tileNodesForLines() + for (let tileNode of tilesNodes) { + expect(tileNode.style['-webkit-transform']).toBe('translate3d(0px, ' + top + 'px, 0px)') + top += tileNode.offsetHeight + } + expect(horizontalScrollbarNode.scrollLeft).toBe(0) + wrapperNode.setScrollLeft(100) + + runAnimationFrames() + + top = 0 + for (let tileNode of tilesNodes) { + expect(tileNode.style['-webkit-transform']).toBe('translate3d(-100px, ' + top + 'px, 0px)') + top += tileNode.offsetHeight + } + expect(horizontalScrollbarNode.scrollLeft).toBe(100) + }) + + it('updates the scrollLeft of the model when the scrollLeft of the horizontal scrollbar changes', function () { + componentNode.style.width = 30 * charWidth + 'px' + component.measureDimensions() + runAnimationFrames() + expect(wrapperNode.getScrollLeft()).toBe(0) + horizontalScrollbarNode.scrollLeft = 100 + horizontalScrollbarNode.dispatchEvent(new UIEvent('scroll')) + runAnimationFrames(true) + expect(wrapperNode.getScrollLeft()).toBe(100) + }) + + it('does not obscure the last line with the horizontal scrollbar', function () { + wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' + wrapperNode.style.width = 10 * charWidth + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + wrapperNode.setScrollBottom(wrapperNode.getScrollHeight()) + runAnimationFrames() + + let lastLineNode = component.lineNodeForScreenRow(editor.getLastScreenRow()) + let bottomOfLastLine = lastLineNode.getBoundingClientRect().bottom + topOfHorizontalScrollbar = horizontalScrollbarNode.getBoundingClientRect().top + expect(bottomOfLastLine).toBe(topOfHorizontalScrollbar) + wrapperNode.style.width = 100 * charWidth + 'px' + component.measureDimensions() + runAnimationFrames() + + bottomOfLastLine = lastLineNode.getBoundingClientRect().bottom + let bottomOfEditor = componentNode.getBoundingClientRect().bottom + expect(bottomOfLastLine).toBe(bottomOfEditor) + }) + + it('does not obscure the last character of the longest line with the vertical scrollbar', function () { + wrapperNode.style.height = 7 * lineHeightInPixels + 'px' + wrapperNode.style.width = 10 * charWidth + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + wrapperNode.setScrollLeft(Infinity) + + runAnimationFrames() + let rightOfLongestLine = component.lineNodeForScreenRow(6).querySelector('.line > span:last-child').getBoundingClientRect().right + let leftOfVerticalScrollbar = verticalScrollbarNode.getBoundingClientRect().left + expect(Math.round(rightOfLongestLine)).toBeCloseTo(leftOfVerticalScrollbar - 1, 0) + }) + + it('only displays dummy scrollbars when scrollable in that direction', function () { + expect(verticalScrollbarNode.style.display).toBe('none') + expect(horizontalScrollbarNode.style.display).toBe('none') + wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' + wrapperNode.style.width = '1000px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + expect(verticalScrollbarNode.style.display).toBe('') + expect(horizontalScrollbarNode.style.display).toBe('none') + componentNode.style.width = 10 * charWidth + 'px' + component.measureDimensions() + runAnimationFrames() + + expect(verticalScrollbarNode.style.display).toBe('') + expect(horizontalScrollbarNode.style.display).toBe('') + wrapperNode.style.height = 20 * lineHeightInPixels + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + expect(verticalScrollbarNode.style.display).toBe('none') + expect(horizontalScrollbarNode.style.display).toBe('') + }) + + it('makes the dummy scrollbar divs only as tall/wide as the actual scrollbars', function () { + wrapperNode.style.height = 4 * lineHeightInPixels + 'px' + wrapperNode.style.width = 10 * charWidth + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + atom.styles.addStyleSheet('::-webkit-scrollbar {\n width: 8px;\n height: 8px;\n}', { + context: 'atom-text-editor' + }) + + runAnimationFrames() + runAnimationFrames() + + let scrollbarCornerNode = componentNode.querySelector('.scrollbar-corner') + expect(verticalScrollbarNode.offsetWidth).toBe(8) + expect(horizontalScrollbarNode.offsetHeight).toBe(8) + expect(scrollbarCornerNode.offsetWidth).toBe(8) + expect(scrollbarCornerNode.offsetHeight).toBe(8) + atom.themes.removeStylesheet('test') + }) + + it('assigns the bottom/right of the scrollbars to the width of the opposite scrollbar if it is visible', function () { + let scrollbarCornerNode = componentNode.querySelector('.scrollbar-corner') + expect(verticalScrollbarNode.style.bottom).toBe('0px') + expect(horizontalScrollbarNode.style.right).toBe('0px') + wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' + wrapperNode.style.width = '1000px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + expect(verticalScrollbarNode.style.bottom).toBe('0px') + expect(horizontalScrollbarNode.style.right).toBe(verticalScrollbarNode.offsetWidth + 'px') + expect(scrollbarCornerNode.style.display).toBe('none') + componentNode.style.width = 10 * charWidth + 'px' + component.measureDimensions() + runAnimationFrames() + + expect(verticalScrollbarNode.style.bottom).toBe(horizontalScrollbarNode.offsetHeight + 'px') + expect(horizontalScrollbarNode.style.right).toBe(verticalScrollbarNode.offsetWidth + 'px') + expect(scrollbarCornerNode.style.display).toBe('') + wrapperNode.style.height = 20 * lineHeightInPixels + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + expect(verticalScrollbarNode.style.bottom).toBe(horizontalScrollbarNode.offsetHeight + 'px') + expect(horizontalScrollbarNode.style.right).toBe('0px') + expect(scrollbarCornerNode.style.display).toBe('none') + }) + + it('accounts for the width of the gutter in the scrollWidth of the horizontal scrollbar', function () { + let gutterNode = componentNode.querySelector('.gutter') + componentNode.style.width = 10 * charWidth + 'px' + component.measureDimensions() + runAnimationFrames() + + expect(horizontalScrollbarNode.scrollWidth).toBe(wrapperNode.getScrollWidth()) + expect(horizontalScrollbarNode.style.left).toBe('0px') + }) + }) + + describe('mousewheel events', function () { + beforeEach(function () { + editor.update({scrollSensitivity: 100}) + }) + + describe('updating scrollTop and scrollLeft', function () { + beforeEach(function () { + wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' + wrapperNode.style.width = 20 * charWidth + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + }) + + it('updates the scrollLeft or scrollTop on mousewheel events depending on which delta is greater (x or y)', function () { + expect(verticalScrollbarNode.scrollTop).toBe(0) + expect(horizontalScrollbarNode.scrollLeft).toBe(0) + componentNode.dispatchEvent(new WheelEvent('mousewheel', { + wheelDeltaX: -5, + wheelDeltaY: -10 + })) + runAnimationFrames() + + expect(verticalScrollbarNode.scrollTop).toBe(10) + expect(horizontalScrollbarNode.scrollLeft).toBe(0) + componentNode.dispatchEvent(new WheelEvent('mousewheel', { + wheelDeltaX: -15, + wheelDeltaY: -5 + })) + runAnimationFrames() + + expect(verticalScrollbarNode.scrollTop).toBe(10) + expect(horizontalScrollbarNode.scrollLeft).toBe(15) + }) + + it('updates the scrollLeft or scrollTop according to the scroll sensitivity', function () { + editor.update({scrollSensitivity: 50}) + componentNode.dispatchEvent(new WheelEvent('mousewheel', { + wheelDeltaX: -5, + wheelDeltaY: -10 + })) + runAnimationFrames() + + expect(horizontalScrollbarNode.scrollLeft).toBe(0) + componentNode.dispatchEvent(new WheelEvent('mousewheel', { + wheelDeltaX: -15, + wheelDeltaY: -5 + })) + runAnimationFrames() + + expect(verticalScrollbarNode.scrollTop).toBe(5) + expect(horizontalScrollbarNode.scrollLeft).toBe(7) + }) + }) + + describe('when the mousewheel event\'s target is a line', function () { + it('keeps the line on the DOM if it is scrolled off-screen', function () { + component.presenter.stoppedScrollingDelay = 3000 // account for slower build machines + wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' + wrapperNode.style.width = 20 * charWidth + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + let lineNode = componentNode.querySelector('.line') + let wheelEvent = new WheelEvent('mousewheel', { + wheelDeltaX: 0, + wheelDeltaY: -500 + }) + Object.defineProperty(wheelEvent, 'target', { + get: function () { + return lineNode + } + }) + componentNode.dispatchEvent(wheelEvent) + runAnimationFrames() + + expect(componentNode.contains(lineNode)).toBe(true) + }) + + it('does not set the mouseWheelScreenRow if scrolling horizontally', function () { + wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' + wrapperNode.style.width = 20 * charWidth + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + let lineNode = componentNode.querySelector('.line') + let wheelEvent = new WheelEvent('mousewheel', { + wheelDeltaX: 10, + wheelDeltaY: 0 + }) + Object.defineProperty(wheelEvent, 'target', { + get: function () { + return lineNode + } + }) + componentNode.dispatchEvent(wheelEvent) + runAnimationFrames() + + expect(component.presenter.mouseWheelScreenRow).toBe(null) + }) + + it('clears the mouseWheelScreenRow after a delay even if the event does not cause scrolling', async function () { + expect(wrapperNode.getScrollTop()).toBe(0) + let lineNode = componentNode.querySelector('.line') + let wheelEvent = new WheelEvent('mousewheel', { + wheelDeltaX: 0, + wheelDeltaY: 10 + }) + Object.defineProperty(wheelEvent, 'target', { + get: function () { + return lineNode + } + }) + componentNode.dispatchEvent(wheelEvent) + expect(wrapperNode.getScrollTop()).toBe(0) + expect(component.presenter.mouseWheelScreenRow).toBe(0) + + advanceClock(component.presenter.stoppedScrollingDelay) + expect(component.presenter.mouseWheelScreenRow).toBeNull() + }) + + it('does not preserve the line if it is on screen', function () { + let lineNode, lineNodes, wheelEvent + expect(componentNode.querySelectorAll('.line-number').length).toBe(14) + lineNodes = componentNode.querySelectorAll('.line') + expect(lineNodes.length).toBe(13) + lineNode = lineNodes[0] + wheelEvent = new WheelEvent('mousewheel', { + wheelDeltaX: 0, + wheelDeltaY: 100 + }) + Object.defineProperty(wheelEvent, 'target', { + get: function () { + return lineNode + } + }) + componentNode.dispatchEvent(wheelEvent) + expect(component.presenter.mouseWheelScreenRow).toBe(0) + editor.insertText('hello') + expect(componentNode.querySelectorAll('.line-number').length).toBe(14) + expect(componentNode.querySelectorAll('.line').length).toBe(13) + }) + }) + + describe('when the mousewheel event\'s target is a line number', function () { + it('keeps the line number on the DOM if it is scrolled off-screen', function () { + wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' + wrapperNode.style.width = 20 * charWidth + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + let lineNumberNode = componentNode.querySelectorAll('.line-number')[1] + let wheelEvent = new WheelEvent('mousewheel', { + wheelDeltaX: 0, + wheelDeltaY: -500 + }) + Object.defineProperty(wheelEvent, 'target', { + get: function () { + return lineNumberNode + } + }) + componentNode.dispatchEvent(wheelEvent) + runAnimationFrames() + + expect(componentNode.contains(lineNumberNode)).toBe(true) + }) + }) + + describe('when the mousewheel event\'s target is a block decoration', function () { + it('keeps it on the DOM if it is scrolled off-screen', function () { + wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' + wrapperNode.style.width = 20 * charWidth + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + let item = document.createElement("div") + item.style.width = "30px" + item.style.height = "30px" + item.className = "decoration-1" + editor.decorateMarker( + editor.markScreenPosition([0, 0], {invalidate: "never"}), + {type: "block", item: item} + ) + + runAnimationFrames() + + let wheelEvent = new WheelEvent('mousewheel', { + wheelDeltaX: 0, + wheelDeltaY: -500 + }) + Object.defineProperty(wheelEvent, 'target', { + get: function () { + return item + } + }) + componentNode.dispatchEvent(wheelEvent) + runAnimationFrames() + + expect(component.getTopmostDOMNode().contains(item)).toBe(true) + }) + }) + + describe('when the mousewheel event\'s target is an SVG element inside a block decoration', function () { + it('keeps the block decoration on the DOM if it is scrolled off-screen', function () { + wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' + wrapperNode.style.width = 20 * charWidth + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + const item = document.createElement('div') + const svgElement = document.createElementNS("http://www.w3.org/2000/svg", "svg") + item.appendChild(svgElement) + editor.decorateMarker( + editor.markScreenPosition([0, 0], {invalidate: "never"}), + {type: "block", item: item} + ) + + runAnimationFrames() + + let wheelEvent = new WheelEvent('mousewheel', { + wheelDeltaX: 0, + wheelDeltaY: -500 + }) + Object.defineProperty(wheelEvent, 'target', { + get: function () { + return svgElement + } + }) + componentNode.dispatchEvent(wheelEvent) + runAnimationFrames() + + expect(component.getTopmostDOMNode().contains(item)).toBe(true) + }) + }) + + it('only prevents the default action of the mousewheel event if it actually lead to scrolling', function () { + spyOn(WheelEvent.prototype, 'preventDefault').andCallThrough() + wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' + wrapperNode.style.width = 20 * charWidth + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + componentNode.dispatchEvent(new WheelEvent('mousewheel', { + wheelDeltaX: 0, + wheelDeltaY: 50 + })) + expect(wrapperNode.getScrollTop()).toBe(0) + expect(WheelEvent.prototype.preventDefault).not.toHaveBeenCalled() + componentNode.dispatchEvent(new WheelEvent('mousewheel', { + wheelDeltaX: 0, + wheelDeltaY: -3000 + })) + runAnimationFrames() + + let maxScrollTop = wrapperNode.getScrollTop() + expect(WheelEvent.prototype.preventDefault).toHaveBeenCalled() + WheelEvent.prototype.preventDefault.reset() + componentNode.dispatchEvent(new WheelEvent('mousewheel', { + wheelDeltaX: 0, + wheelDeltaY: -30 + })) + expect(wrapperNode.getScrollTop()).toBe(maxScrollTop) + expect(WheelEvent.prototype.preventDefault).not.toHaveBeenCalled() + componentNode.dispatchEvent(new WheelEvent('mousewheel', { + wheelDeltaX: 50, + wheelDeltaY: 0 + })) + expect(wrapperNode.getScrollLeft()).toBe(0) + expect(WheelEvent.prototype.preventDefault).not.toHaveBeenCalled() + componentNode.dispatchEvent(new WheelEvent('mousewheel', { + wheelDeltaX: -3000, + wheelDeltaY: 0 + })) + runAnimationFrames() + + let maxScrollLeft = wrapperNode.getScrollLeft() + expect(WheelEvent.prototype.preventDefault).toHaveBeenCalled() + WheelEvent.prototype.preventDefault.reset() + componentNode.dispatchEvent(new WheelEvent('mousewheel', { + wheelDeltaX: -30, + wheelDeltaY: 0 + })) + expect(wrapperNode.getScrollLeft()).toBe(maxScrollLeft) + expect(WheelEvent.prototype.preventDefault).not.toHaveBeenCalled() + }) + }) + + describe('input events', function () { + function buildTextInputEvent ({data, target}) { + let event = new Event('textInput') + event.data = data + Object.defineProperty(event, 'target', { + get: function () { + return target + } + }) + return event + } + + function buildKeydownEvent ({keyCode, target}) { + let event = new KeyboardEvent('keydown') + Object.defineProperty(event, 'keyCode', { + get: function () { + return keyCode + } + }) + Object.defineProperty(event, 'target', { + get: function () { + return target + } + }) + return event + } + + let inputNode + + beforeEach(function () { + inputNode = componentNode.querySelector('.hidden-input') + }) + + it('inserts the newest character in the input\'s value into the buffer', function () { + componentNode.dispatchEvent(buildTextInputEvent({ + data: 'x', + target: inputNode + })) + runAnimationFrames() + + expect(editor.lineTextForBufferRow(0)).toBe('xvar quicksort = function () {') + componentNode.dispatchEvent(buildTextInputEvent({ + data: 'y', + target: inputNode + })) + + expect(editor.lineTextForBufferRow(0)).toBe('xyvar quicksort = function () {') + }) + + it('replaces the last character if a keypress event is bracketed by keydown events with matching keyCodes, which occurs when the accented character menu is shown', function () { + componentNode.dispatchEvent(buildKeydownEvent({keyCode: 85, target: inputNode})) + componentNode.dispatchEvent(buildTextInputEvent({data: 'u', target: inputNode})) + componentNode.dispatchEvent(new KeyboardEvent('keypress')) + componentNode.dispatchEvent(buildKeydownEvent({keyCode: 85, target: inputNode})) + componentNode.dispatchEvent(new KeyboardEvent('keyup')) + runAnimationFrames() + + expect(editor.lineTextForBufferRow(0)).toBe('uvar quicksort = function () {') + inputNode.setSelectionRange(0, 1) + componentNode.dispatchEvent(buildTextInputEvent({ + data: 'ü', + target: inputNode + })) + runAnimationFrames() + + expect(editor.lineTextForBufferRow(0)).toBe('üvar quicksort = function () {') + }) + + it('does not handle input events when input is disabled', function () { + component.setInputEnabled(false) + componentNode.dispatchEvent(buildTextInputEvent({ + data: 'x', + target: inputNode + })) + expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () {') + runAnimationFrames() + expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () {') + }) + + it('groups events that occur close together in time into single undo entries', function () { + let currentTime = 0 + spyOn(Date, 'now').andCallFake(function () { + return currentTime + }) + editor.update({undoGroupingInterval: 100}) + editor.setText('') + componentNode.dispatchEvent(buildTextInputEvent({ + data: 'x', + target: inputNode + })) + currentTime += 99 + componentNode.dispatchEvent(buildTextInputEvent({ + data: 'y', + target: inputNode + })) + currentTime += 99 + componentNode.dispatchEvent(new CustomEvent('editor:duplicate-lines', { + bubbles: true, + cancelable: true + })) + currentTime += 101 + componentNode.dispatchEvent(new CustomEvent('editor:duplicate-lines', { + bubbles: true, + cancelable: true + })) + expect(editor.getText()).toBe('xy\nxy\nxy') + componentNode.dispatchEvent(new CustomEvent('core:undo', { + bubbles: true, + cancelable: true + })) + expect(editor.getText()).toBe('xy\nxy') + componentNode.dispatchEvent(new CustomEvent('core:undo', { + bubbles: true, + cancelable: true + })) + expect(editor.getText()).toBe('') + }) + + describe('when IME composition is used to insert international characters', function () { + function buildIMECompositionEvent (event, {data, target} = {}) { + event = new Event(event) + event.data = data + Object.defineProperty(event, 'target', { + get: function () { + return target + } + }) + return event + } + + let inputNode + + beforeEach(function () { + inputNode = componentNode.querySelector('.hidden-input') + }) + + describe('when nothing is selected', function () { + it('inserts the chosen completion', function () { + componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', { + target: inputNode + })) + componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { + data: 's', + target: inputNode + })) + expect(editor.lineTextForBufferRow(0)).toBe('svar quicksort = function () {') + componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { + data: 'sd', + target: inputNode + })) + expect(editor.lineTextForBufferRow(0)).toBe('sdvar quicksort = function () {') + componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', { + target: inputNode + })) + componentNode.dispatchEvent(buildTextInputEvent({ + data: '速度', + target: inputNode + })) + expect(editor.lineTextForBufferRow(0)).toBe('速度var quicksort = function () {') + }) + + it('reverts back to the original text when the completion helper is dismissed', function () { + componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', { + target: inputNode + })) + componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { + data: 's', + target: inputNode + })) + expect(editor.lineTextForBufferRow(0)).toBe('svar quicksort = function () {') + componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { + data: 'sd', + target: inputNode + })) + expect(editor.lineTextForBufferRow(0)).toBe('sdvar quicksort = function () {') + componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', { + target: inputNode + })) + expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () {') + }) + + it('allows multiple accented character to be inserted with the \' on a US international layout', function () { + inputNode.value = '\'' + inputNode.setSelectionRange(0, 1) + componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', { + target: inputNode + })) + componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { + data: '\'', + target: inputNode + })) + expect(editor.lineTextForBufferRow(0)).toBe('\'var quicksort = function () {') + componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', { + target: inputNode + })) + componentNode.dispatchEvent(buildTextInputEvent({ + data: 'á', + target: inputNode + })) + expect(editor.lineTextForBufferRow(0)).toBe('ávar quicksort = function () {') + inputNode.value = '\'' + inputNode.setSelectionRange(0, 1) + componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', { + target: inputNode + })) + componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { + data: '\'', + target: inputNode + })) + expect(editor.lineTextForBufferRow(0)).toBe('á\'var quicksort = function () {') + componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', { + target: inputNode + })) + componentNode.dispatchEvent(buildTextInputEvent({ + data: 'á', + target: inputNode + })) + expect(editor.lineTextForBufferRow(0)).toBe('áávar quicksort = function () {') + }) + }) + + describe('when a string is selected', function () { + beforeEach(function () { + editor.setSelectedBufferRanges([[[0, 4], [0, 9]], [[0, 16], [0, 19]]]) + }) + + it('inserts the chosen completion', function () { + componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', { + target: inputNode + })) + componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { + data: 's', + target: inputNode + })) + expect(editor.lineTextForBufferRow(0)).toBe('var ssort = sction () {') + componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { + data: 'sd', + target: inputNode + })) + expect(editor.lineTextForBufferRow(0)).toBe('var sdsort = sdction () {') + componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', { + target: inputNode + })) + componentNode.dispatchEvent(buildTextInputEvent({ + data: '速度', + target: inputNode + })) + expect(editor.lineTextForBufferRow(0)).toBe('var 速度sort = 速度ction () {') + }) + + it('reverts back to the original text when the completion helper is dismissed', function () { + componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', { + target: inputNode + })) + componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { + data: 's', + target: inputNode + })) + expect(editor.lineTextForBufferRow(0)).toBe('var ssort = sction () {') + componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { + data: 'sd', + target: inputNode + })) + expect(editor.lineTextForBufferRow(0)).toBe('var sdsort = sdction () {') + componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', { + target: inputNode + })) + expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () {') + }) + }) + }) + }) + + describe('commands', function () { + describe('editor:consolidate-selections', function () { + it('consolidates selections on the editor model, aborting the key binding if there is only one selection', function () { + spyOn(editor, 'consolidateSelections').andCallThrough() + let event = new CustomEvent('editor:consolidate-selections', { + bubbles: true, + cancelable: true + }) + event.abortKeyBinding = jasmine.createSpy('event.abortKeyBinding') + componentNode.dispatchEvent(event) + expect(editor.consolidateSelections).toHaveBeenCalled() + expect(event.abortKeyBinding).toHaveBeenCalled() + }) + }) + }) + + describe('when decreasing the fontSize', function () { + it('decreases the widths of the korean char, the double width char and the half width char', function () { + originalDefaultCharWidth = editor.getDefaultCharWidth() + koreanDefaultCharWidth = editor.getKoreanCharWidth() + doubleWidthDefaultCharWidth = editor.getDoubleWidthCharWidth() + halfWidthDefaultCharWidth = editor.getHalfWidthCharWidth() + component.setFontSize(10) + runAnimationFrames() + expect(editor.getDefaultCharWidth()).toBeLessThan(originalDefaultCharWidth) + expect(editor.getKoreanCharWidth()).toBeLessThan(koreanDefaultCharWidth) + expect(editor.getDoubleWidthCharWidth()).toBeLessThan(doubleWidthDefaultCharWidth) + expect(editor.getHalfWidthCharWidth()).toBeLessThan(halfWidthDefaultCharWidth) + }) + }) + + describe('when increasing the fontSize', function() { + it('increases the widths of the korean char, the double width char and the half width char', function () { + originalDefaultCharWidth = editor.getDefaultCharWidth() + koreanDefaultCharWidth = editor.getKoreanCharWidth() + doubleWidthDefaultCharWidth = editor.getDoubleWidthCharWidth() + halfWidthDefaultCharWidth = editor.getHalfWidthCharWidth() + component.setFontSize(25) + runAnimationFrames() + expect(editor.getDefaultCharWidth()).toBeGreaterThan(originalDefaultCharWidth) + expect(editor.getKoreanCharWidth()).toBeGreaterThan(koreanDefaultCharWidth) + expect(editor.getDoubleWidthCharWidth()).toBeGreaterThan(doubleWidthDefaultCharWidth) + expect(editor.getHalfWidthCharWidth()).toBeGreaterThan(halfWidthDefaultCharWidth) + }) + }) + + describe('hiding and showing the editor', function () { + beforeEach(function () { + spyOn(component, 'becameVisible').andCallThrough() + }) + + describe('when the editor is hidden when it is mounted', function () { + it('defers measurement and rendering until the editor becomes visible', async function () { + wrapperNode.remove() + let hiddenParent = document.createElement('div') + hiddenParent.style.display = 'none' + contentNode.appendChild(hiddenParent) + wrapperNode = new TextEditorElement() + wrapperNode.tileSize = TILE_SIZE + wrapperNode.initialize(editor, atom) + hiddenParent.appendChild(wrapperNode) + component = wrapperNode.component + spyOn(component, 'becameVisible').andCallThrough() + componentNode = component.getDomNode() + expect(componentNode.querySelectorAll('.line').length).toBe(0) + hiddenParent.style.display = 'block' + await conditionPromise(() => component.becameVisible.callCount > 0) + expect(componentNode.querySelectorAll('.line').length).toBeGreaterThan(0) + }) + }) + + describe('when the lineHeight changes while the editor is hidden', function () { + it('does not attempt to measure the lineHeightInPixels until the editor becomes visible again', async function () { + wrapperNode.style.display = 'none' + let initialLineHeightInPixels = editor.getLineHeightInPixels() + component.setLineHeight(2) + expect(editor.getLineHeightInPixels()).toBe(initialLineHeightInPixels) + wrapperNode.style.display = '' + await conditionPromise(() => component.becameVisible.callCount > 0) + expect(editor.getLineHeightInPixels()).not.toBe(initialLineHeightInPixels) + }) + }) + + describe('when the fontSize changes while the editor is hidden', function () { + it('does not attempt to measure the lineHeightInPixels or defaultCharWidth until the editor becomes visible again', async function () { + wrapperNode.style.display = 'none' + let initialLineHeightInPixels = editor.getLineHeightInPixels() + let initialCharWidth = editor.getDefaultCharWidth() + component.setFontSize(22) + expect(editor.getLineHeightInPixels()).toBe(initialLineHeightInPixels) + expect(editor.getDefaultCharWidth()).toBe(initialCharWidth) + wrapperNode.style.display = '' + await conditionPromise(() => component.becameVisible.callCount > 0) + expect(editor.getLineHeightInPixels()).not.toBe(initialLineHeightInPixels) + expect(editor.getDefaultCharWidth()).not.toBe(initialCharWidth) + }) + + it('does not re-measure character widths until the editor is shown again', async function () { + wrapperNode.style.display = 'none' + component.setFontSize(22) + editor.getBuffer().insert([0, 0], 'a') + wrapperNode.style.display = '' + await conditionPromise(() => component.becameVisible.callCount > 0) + editor.setCursorBufferPosition([0, Infinity]) + runAnimationFrames() + let cursorLeft = componentNode.querySelector('.cursor').getBoundingClientRect().left + let line0Right = componentNode.querySelector('.line > span:last-child').getBoundingClientRect().right + expect(cursorLeft).toBeCloseTo(line0Right, 0) + }) + }) + + describe('when the fontFamily changes while the editor is hidden', function () { + it('does not attempt to measure the defaultCharWidth until the editor becomes visible again', async function () { + wrapperNode.style.display = 'none' + let initialLineHeightInPixels = editor.getLineHeightInPixels() + let initialCharWidth = editor.getDefaultCharWidth() + component.setFontFamily('serif') + expect(editor.getDefaultCharWidth()).toBe(initialCharWidth) + wrapperNode.style.display = '' + await conditionPromise(() => component.becameVisible.callCount > 0) + expect(editor.getDefaultCharWidth()).not.toBe(initialCharWidth) + }) + + it('does not re-measure character widths until the editor is shown again', async function () { + wrapperNode.style.display = 'none' + component.setFontFamily('serif') + wrapperNode.style.display = '' + await conditionPromise(() => component.becameVisible.callCount > 0) + editor.setCursorBufferPosition([0, Infinity]) + runAnimationFrames() + let cursorLeft = componentNode.querySelector('.cursor').getBoundingClientRect().left + let line0Right = componentNode.querySelector('.line > span:last-child').getBoundingClientRect().right + expect(cursorLeft).toBeCloseTo(line0Right, 0) + }) + }) + + describe('when stylesheets change while the editor is hidden', function () { + afterEach(function () { + atom.themes.removeStylesheet('test') + }) + + it('does not re-measure character widths until the editor is shown again', async function () { + atom.config.set('editor.fontFamily', 'sans-serif') + wrapperNode.style.display = 'none' + atom.themes.applyStylesheet('test', '.syntax--function.syntax--js {\n font-weight: bold;\n}') + wrapperNode.style.display = '' + await conditionPromise(() => component.becameVisible.callCount > 0) + editor.setCursorBufferPosition([0, Infinity]) + runAnimationFrames() + let cursorLeft = componentNode.querySelector('.cursor').getBoundingClientRect().left + let line0Right = componentNode.querySelector('.line > span:last-child').getBoundingClientRect().right + expect(cursorLeft).toBeCloseTo(line0Right, 0) + }) + }) + }) + + describe('soft wrapping', function () { + beforeEach(function () { + editor.setSoftWrapped(true) + runAnimationFrames() + spyOn(component, 'measureDimensions').andCallThrough() + }) + + it('updates the wrap location when the editor is resized', function () { + let newHeight = 4 * editor.getLineHeightInPixels() + 'px' + expect(parseInt(newHeight)).toBeLessThan(wrapperNode.offsetHeight) + wrapperNode.style.height = newHeight + editor.update({autoHeight: false}) + component.measureDimensions() // Called by element resize detector + runAnimationFrames() + + expect(componentNode.querySelectorAll('.line')).toHaveLength(7) + let gutterWidth = componentNode.querySelector('.gutter').offsetWidth + componentNode.style.width = gutterWidth + 14 * charWidth + wrapperNode.getVerticalScrollbarWidth() + 'px' + + component.measureDimensions() // Called by element resize detector + runAnimationFrames() + expect(componentNode.querySelector('.line').textContent).toBe('var quicksort ') + }) + + it('accounts for the scroll view\'s padding when determining the wrap location', function () { + let scrollViewNode = componentNode.querySelector('.scroll-view') + scrollViewNode.style.paddingLeft = 20 + 'px' + componentNode.style.width = 30 * charWidth + 'px' + component.measureDimensions() // Called by element resize detector + runAnimationFrames() + expect(component.lineNodeForScreenRow(0).textContent).toBe('var quicksort = ') + }) + }) + + describe('default decorations', function () { + it('applies .cursor-line decorations for line numbers overlapping selections', function () { + editor.setCursorScreenPosition([4, 4]) + runAnimationFrames() + + expect(lineNumberHasClass(3, 'cursor-line')).toBe(false) + expect(lineNumberHasClass(4, 'cursor-line')).toBe(true) + expect(lineNumberHasClass(5, 'cursor-line')).toBe(false) + editor.setSelectedScreenRange([[3, 4], [4, 4]]) + runAnimationFrames() + + expect(lineNumberHasClass(3, 'cursor-line')).toBe(true) + expect(lineNumberHasClass(4, 'cursor-line')).toBe(true) + editor.setSelectedScreenRange([[3, 4], [4, 0]]) + runAnimationFrames() + + expect(lineNumberHasClass(3, 'cursor-line')).toBe(true) + expect(lineNumberHasClass(4, 'cursor-line')).toBe(false) + }) + + it('does not apply .cursor-line to the last line of a selection if it\'s empty', function () { + editor.setSelectedScreenRange([[3, 4], [5, 0]]) + runAnimationFrames() + expect(lineNumberHasClass(3, 'cursor-line')).toBe(true) + expect(lineNumberHasClass(4, 'cursor-line')).toBe(true) + expect(lineNumberHasClass(5, 'cursor-line')).toBe(false) + }) + + it('applies .cursor-line decorations for lines containing the cursor in non-empty selections', function () { + editor.setCursorScreenPosition([4, 4]) + runAnimationFrames() + + expect(lineHasClass(3, 'cursor-line')).toBe(false) + expect(lineHasClass(4, 'cursor-line')).toBe(true) + expect(lineHasClass(5, 'cursor-line')).toBe(false) + editor.setSelectedScreenRange([[3, 4], [4, 4]]) + runAnimationFrames() + + expect(lineHasClass(2, 'cursor-line')).toBe(false) + expect(lineHasClass(3, 'cursor-line')).toBe(false) + expect(lineHasClass(4, 'cursor-line')).toBe(false) + expect(lineHasClass(5, 'cursor-line')).toBe(false) + }) + + it('applies .cursor-line-no-selection to line numbers for rows containing the cursor when the selection is empty', function () { + editor.setCursorScreenPosition([4, 4]) + runAnimationFrames() + + expect(lineNumberHasClass(4, 'cursor-line-no-selection')).toBe(true) + editor.setSelectedScreenRange([[3, 4], [4, 4]]) + runAnimationFrames() + + expect(lineNumberHasClass(4, 'cursor-line-no-selection')).toBe(false) + }) + }) + + describe('height', function () { + describe('when autoHeight is true', function () { + it('assigns the editor\'s height to based on its contents', function () { + jasmine.attachToDOM(wrapperNode) + expect(editor.getAutoHeight()).toBe(true) + expect(wrapperNode.offsetHeight).toBe(editor.getLineHeightInPixels() * editor.getScreenLineCount()) + editor.insertText('\n\n\n') + runAnimationFrames() + expect(wrapperNode.offsetHeight).toBe(editor.getLineHeightInPixels() * editor.getScreenLineCount()) + }) + }) + + describe('when autoHeight is false', function () { + it('does not assign the height of the editor, instead allowing content to scroll', function () { + jasmine.attachToDOM(wrapperNode) + editor.update({autoHeight: false}) + wrapperNode.style.height = '200px' + expect(wrapperNode.offsetHeight).toBe(200) + editor.insertText('\n\n\n') + runAnimationFrames() + expect(wrapperNode.offsetHeight).toBe(200) + }) + }) + + describe('when autoHeight is not assigned on the editor', function () { + it('implicitly assigns autoHeight to true and emits a deprecation warning if the editor has its height assigned via an inline style', function () { + editor = new TextEditor() + element = editor.getElement() + element.setUpdatedSynchronously(false) + element.style.height = '200px' + + spyOn(Grim, 'deprecate') + jasmine.attachToDOM(element) + + expect(element.offsetHeight).toBe(200) + expect(element.querySelector('.editor-contents--private').offsetHeight).toBe(200) + expect(Grim.deprecate.callCount).toBe(1) + expect(Grim.deprecate.argsForCall[0][0]).toMatch(/inline style/) + }) + + it('implicitly assigns autoHeight to true and emits a deprecation warning if the editor has its height assigned via position absolute with an assigned top and bottom', function () { + editor = new TextEditor() + element = editor.getElement() + element.setUpdatedSynchronously(false) + parentElement = document.createElement('div') + parentElement.style.position = 'absolute' + parentElement.style.height = '200px' + element.style.position = 'absolute' + element.style.top = '0px' + element.style.bottom = '0px' + parentElement.appendChild(element) + + spyOn(Grim, 'deprecate') + + jasmine.attachToDOM(parentElement) + element.component.measureDimensions() + + expect(element.offsetHeight).toBe(200) + expect(element.querySelector('.editor-contents--private').offsetHeight).toBe(200) + expect(Grim.deprecate.callCount).toBe(1) + expect(Grim.deprecate.argsForCall[0][0]).toMatch(/absolute/) + }) + }) + + describe('when the wrapper view has an explicit height', function () { + it('does not assign a height on the component node', function () { + wrapperNode.style.height = '200px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + expect(componentNode.style.height).toBe('') + }) + }) + + describe('when the wrapper view does not have an explicit height', function () { + it('assigns a height on the component node based on the editor\'s content', function () { + expect(wrapperNode.style.height).toBe('') + expect(componentNode.style.height).toBe(editor.getScreenLineCount() * lineHeightInPixels + 'px') + }) + }) + }) + + describe('width', function () { + it('sizes the editor element according to the content width when auto width is true, or according to the container width otherwise', function () { + contentNode.style.width = '600px' + component.measureDimensions() + editor.setText("abcdefghi") + runAnimationFrames() + expect(wrapperNode.offsetWidth).toBe(contentNode.offsetWidth) + + editor.update({autoWidth: true}) + runAnimationFrames() + const editorWidth1 = wrapperNode.offsetWidth + expect(editorWidth1).toBeGreaterThan(0) + expect(editorWidth1).toBeLessThan(contentNode.offsetWidth) + + editor.setText("abcdefghijkl") + editor.update({autoWidth: true}) + runAnimationFrames() + const editorWidth2 = wrapperNode.offsetWidth + expect(editorWidth2).toBeGreaterThan(editorWidth1) + expect(editorWidth2).toBeLessThan(contentNode.offsetWidth) + + editor.update({autoWidth: false}) + runAnimationFrames() + expect(wrapperNode.offsetWidth).toBe(contentNode.offsetWidth) + }) + }) + + describe('when the "mini" property is true', function () { + beforeEach(function () { + editor.setMini(true) + runAnimationFrames() + }) + + it('does not render the gutter', function () { + expect(componentNode.querySelector('.gutter')).toBeNull() + }) + + it('adds the "mini" class to the wrapper view', function () { + expect(wrapperNode.classList.contains('mini')).toBe(true) + }) + + it('does not have an opaque background on lines', function () { + expect(component.linesComponent.getDomNode().getAttribute('style')).not.toContain('background-color') + }) + + it('does not render invisible characters', function () { + editor.update({ + showInvisibles: true, + invisibles: {eol: 'E'} + }) + expect(component.lineNodeForScreenRow(0).textContent).toBe('var quicksort = function () {') + }) + + it('does not assign an explicit line-height on the editor contents', function () { + expect(componentNode.style.lineHeight).toBe('') + }) + + it('does not apply cursor-line decorations', function () { + expect(component.lineNodeForScreenRow(0).classList.contains('cursor-line')).toBe(false) + }) + }) + + describe('when placholderText is specified', function () { + it('renders the placeholder text when the buffer is empty', function () { + editor.setPlaceholderText('Hello World') + expect(componentNode.querySelector('.placeholder-text')).toBeNull() + editor.setText('') + runAnimationFrames() + + expect(componentNode.querySelector('.placeholder-text').textContent).toBe('Hello World') + editor.setText('hey') + runAnimationFrames() + + expect(componentNode.querySelector('.placeholder-text')).toBeNull() + }) + }) + + describe('grammar data attributes', function () { + it('adds and updates the grammar data attribute based on the current grammar', function () { + expect(wrapperNode.dataset.grammar).toBe('source js') + editor.setGrammar(atom.grammars.nullGrammar) + expect(wrapperNode.dataset.grammar).toBe('text plain null-grammar') + }) + }) + + describe('encoding data attributes', function () { + it('adds and updates the encoding data attribute based on the current encoding', function () { + expect(wrapperNode.dataset.encoding).toBe('utf8') + editor.setEncoding('utf16le') + expect(wrapperNode.dataset.encoding).toBe('utf16le') + }) + }) + + describe('detaching and reattaching the editor (regression)', function () { + it('does not throw an exception', function () { + wrapperNode.remove() + jasmine.attachToDOM(wrapperNode) + atom.commands.dispatch(wrapperNode, 'core:move-right') + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + }) + }) + + describe('autoscroll', function () { + beforeEach(function () { + editor.setVerticalScrollMargin(2) + editor.setHorizontalScrollMargin(2) + component.setLineHeight('10px') + component.setFontSize(17) + component.measureDimensions() + runAnimationFrames() + + wrapperNode.style.width = 55 + component.getGutterWidth() + 'px' + wrapperNode.style.height = '55px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + component.presenter.setHorizontalScrollbarHeight(0) + component.presenter.setVerticalScrollbarWidth(0) + runAnimationFrames() + }) + + describe('when selecting buffer ranges', function () { + it('autoscrolls the selection if it is last unless the "autoscroll" option is false', function () { + expect(wrapperNode.getScrollTop()).toBe(0) + editor.setSelectedBufferRange([[5, 6], [6, 8]]) + runAnimationFrames() + + let right = wrapperNode.pixelPositionForBufferPosition([6, 8 + editor.getHorizontalScrollMargin()]).left + expect(wrapperNode.getScrollBottom()).toBe((7 + editor.getVerticalScrollMargin()) * 10) + expect(wrapperNode.getScrollRight()).toBeCloseTo(right, 0) + editor.setSelectedBufferRange([[0, 0], [0, 0]]) + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(0) + expect(wrapperNode.getScrollLeft()).toBe(0) + editor.setSelectedBufferRange([[6, 6], [6, 8]]) + runAnimationFrames() + + expect(wrapperNode.getScrollBottom()).toBe((7 + editor.getVerticalScrollMargin()) * 10) + expect(wrapperNode.getScrollRight()).toBeCloseTo(right, 0) + }) + }) + + describe('when adding selections for buffer ranges', function () { + it('autoscrolls to the added selection if needed', function () { + editor.addSelectionForBufferRange([[8, 10], [8, 15]]) + runAnimationFrames() + + let right = wrapperNode.pixelPositionForBufferPosition([8, 15]).left + expect(wrapperNode.getScrollBottom()).toBe((9 * 10) + (2 * 10)) + expect(wrapperNode.getScrollRight()).toBeCloseTo(right + 2 * 10, 0) + }) + }) + + describe('when selecting lines containing cursors', function () { + it('autoscrolls to the selection', function () { + editor.setCursorScreenPosition([5, 6]) + runAnimationFrames() + + wrapperNode.scrollToTop() + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(0) + editor.selectLinesContainingCursors() + runAnimationFrames() + + expect(wrapperNode.getScrollBottom()).toBe((7 + editor.getVerticalScrollMargin()) * 10) + }) + }) + + describe('when inserting text', function () { + describe('when there are multiple empty selections on different lines', function () { + it('autoscrolls to the last cursor', function () { + editor.setCursorScreenPosition([1, 2], { + autoscroll: false + }) + runAnimationFrames() + + editor.addCursorAtScreenPosition([10, 4], { + autoscroll: false + }) + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(0) + editor.insertText('a') + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(75) + }) + }) + }) + + describe('when scrolled to cursor position', function () { + it('scrolls the last cursor into view, centering around the cursor if possible and the "center" option is not false', function () { + editor.setCursorScreenPosition([8, 8], { + autoscroll: false + }) + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(0) + expect(wrapperNode.getScrollLeft()).toBe(0) + editor.scrollToCursorPosition() + runAnimationFrames() + + let right = wrapperNode.pixelPositionForScreenPosition([8, 9 + editor.getHorizontalScrollMargin()]).left + expect(wrapperNode.getScrollTop()).toBe((8.8 * 10) - 30) + expect(wrapperNode.getScrollBottom()).toBe((8.3 * 10) + 30) + expect(wrapperNode.getScrollRight()).toBeCloseTo(right, 0) + wrapperNode.setScrollTop(0) + editor.scrollToCursorPosition({ + center: false + }) + expect(wrapperNode.getScrollTop()).toBe((7.8 - editor.getVerticalScrollMargin()) * 10) + expect(wrapperNode.getScrollBottom()).toBe((9.3 + editor.getVerticalScrollMargin()) * 10) + }) + }) + + describe('moving cursors', function () { + it('scrolls down when the last cursor gets closer than ::verticalScrollMargin to the bottom of the editor', function () { + expect(wrapperNode.getScrollTop()).toBe(0) + expect(wrapperNode.getScrollBottom()).toBe(5.5 * 10) + editor.setCursorScreenPosition([2, 0]) + runAnimationFrames() + + expect(wrapperNode.getScrollBottom()).toBe(5.5 * 10) + editor.moveDown() + runAnimationFrames() + + expect(wrapperNode.getScrollBottom()).toBe(6 * 10) + editor.moveDown() + runAnimationFrames() + + expect(wrapperNode.getScrollBottom()).toBe(7 * 10) + }) + + it('scrolls up when the last cursor gets closer than ::verticalScrollMargin to the top of the editor', function () { + editor.setCursorScreenPosition([11, 0]) + runAnimationFrames() + + wrapperNode.setScrollBottom(wrapperNode.getScrollHeight()) + runAnimationFrames() + + editor.moveUp() + runAnimationFrames() + + expect(wrapperNode.getScrollBottom()).toBe(wrapperNode.getScrollHeight()) + editor.moveUp() + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(7 * 10) + editor.moveUp() + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(6 * 10) + }) + + it('scrolls right when the last cursor gets closer than ::horizontalScrollMargin to the right of the editor', function () { + expect(wrapperNode.getScrollLeft()).toBe(0) + expect(wrapperNode.getScrollRight()).toBe(5.5 * 10) + editor.setCursorScreenPosition([0, 2]) + runAnimationFrames() + + expect(wrapperNode.getScrollRight()).toBe(5.5 * 10) + editor.moveRight() + runAnimationFrames() + + let margin = component.presenter.getHorizontalScrollMarginInPixels() + let right = wrapperNode.pixelPositionForScreenPosition([0, 4]).left + margin + expect(wrapperNode.getScrollRight()).toBeCloseTo(right, 0) + editor.moveRight() + runAnimationFrames() + + right = wrapperNode.pixelPositionForScreenPosition([0, 5]).left + margin + expect(wrapperNode.getScrollRight()).toBeCloseTo(right, 0) + }) + + it('scrolls left when the last cursor gets closer than ::horizontalScrollMargin to the left of the editor', function () { + wrapperNode.setScrollRight(wrapperNode.getScrollWidth()) + runAnimationFrames() + + expect(wrapperNode.getScrollRight()).toBe(wrapperNode.getScrollWidth()) + editor.setCursorScreenPosition([6, 62], { + autoscroll: false + }) + runAnimationFrames() + + editor.moveLeft() + runAnimationFrames() + + let margin = component.presenter.getHorizontalScrollMarginInPixels() + let left = wrapperNode.pixelPositionForScreenPosition([6, 61]).left - margin + expect(wrapperNode.getScrollLeft()).toBeCloseTo(left, 0) + editor.moveLeft() + runAnimationFrames() + + left = wrapperNode.pixelPositionForScreenPosition([6, 60]).left - margin + expect(wrapperNode.getScrollLeft()).toBeCloseTo(left, 0) + }) + + it('scrolls down when inserting lines makes the document longer than the editor\'s height', function () { + editor.setCursorScreenPosition([13, Infinity]) + editor.insertNewline() + runAnimationFrames() + + expect(wrapperNode.getScrollBottom()).toBe(14 * 10) + editor.insertNewline() + runAnimationFrames() + + expect(wrapperNode.getScrollBottom()).toBe(15 * 10) + }) + + it('autoscrolls to the cursor when it moves due to undo', function () { + editor.insertText('abc') + wrapperNode.setScrollTop(Infinity) + runAnimationFrames() + + editor.undo() + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(0) + }) + + it('does not scroll when the cursor moves into the visible area', function () { + editor.setCursorBufferPosition([0, 0]) + runAnimationFrames() + + wrapperNode.setScrollTop(40) + runAnimationFrames() + + editor.setCursorBufferPosition([6, 0]) + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(40) + }) + + it('honors the autoscroll option on cursor and selection manipulation methods', function () { + expect(wrapperNode.getScrollTop()).toBe(0) + editor.addCursorAtScreenPosition([11, 11], {autoscroll: false}) + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(0) + editor.addCursorAtBufferPosition([11, 11], {autoscroll: false}) + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(0) + editor.setCursorScreenPosition([11, 11], {autoscroll: false}) + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(0) + editor.setCursorBufferPosition([11, 11], {autoscroll: false}) + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(0) + editor.addSelectionForBufferRange([[11, 11], [11, 11]], {autoscroll: false}) + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(0) + editor.addSelectionForScreenRange([[11, 11], [11, 12]], {autoscroll: false}) + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(0) + editor.setSelectedBufferRange([[11, 0], [11, 1]], {autoscroll: false}) + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(0) + editor.setSelectedScreenRange([[11, 0], [11, 6]], {autoscroll: false}) + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(0) + editor.clearSelections({autoscroll: false}) + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(0) + editor.addSelectionForScreenRange([[0, 0], [0, 4]]) + runAnimationFrames() + + editor.getCursors()[0].setScreenPosition([11, 11], {autoscroll: true}) + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBeGreaterThan(0) + editor.getCursors()[0].setBufferPosition([0, 0], {autoscroll: true}) + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(0) + editor.getSelections()[0].setScreenRange([[11, 0], [11, 4]], {autoscroll: true}) + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBeGreaterThan(0) + editor.getSelections()[0].setBufferRange([[0, 0], [0, 4]], {autoscroll: true}) + runAnimationFrames() + + expect(wrapperNode.getScrollTop()).toBe(0) + }) + }) + }) + + describe('::getVisibleRowRange()', function () { + beforeEach(function () { + wrapperNode.style.height = lineHeightInPixels * 8 + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + }) + + it('returns the first and the last visible rows', function () { + component.setScrollTop(0) + runAnimationFrames() + expect(component.getVisibleRowRange()).toEqual([0, 9]) + }) + + it('ends at last buffer row even if there\'s more space available', function () { + wrapperNode.style.height = lineHeightInPixels * 13 + 'px' + editor.update({autoHeight: false}) + component.measureDimensions() + runAnimationFrames() + + component.setScrollTop(60) + runAnimationFrames() + + expect(component.getVisibleRowRange()).toEqual([0, 13]) + }) + }) + + describe('::pixelPositionForScreenPosition()', () => { + it('returns the correct horizontal position, even if it is on a row that has not yet been rendered (regression)', () => { + editor.setTextInBufferRange([[5, 0], [6, 0]], 'hello world\n') + expect(wrapperNode.pixelPositionForScreenPosition([5, Infinity]).left).toBeGreaterThan(0) + }) + }) + + describe('middle mouse paste on Linux', function () { + let originalPlatform + + beforeEach(function () { + originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + value: 'linux' + }) + }) + + afterEach(function () { + Object.defineProperty(process, 'platform', { + value: originalPlatform + }) + }) + + it('pastes the previously selected text at the clicked location', async function () { + let clipboardWrittenTo = false + spyOn(require('electron').ipcRenderer, 'send').andCallFake(function (eventName, selectedText) { + if (eventName === 'write-text-to-selection-clipboard') { + require('../src/safe-clipboard').writeText(selectedText, 'selection') + clipboardWrittenTo = true + } + }) + atom.clipboard.write('') + component.trackSelectionClipboard() + editor.setSelectedBufferRange([[1, 6], [1, 10]]) + advanceClock(0) + + componentNode.querySelector('.scroll-view').dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([10, 0]), { + button: 1 + })) + componentNode.querySelector('.scroll-view').dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenPosition([10, 0]), { + which: 2 + })) + expect(atom.clipboard.read()).toBe('sort') + expect(editor.lineTextForBufferRow(10)).toBe('sort') + }) + }) + + function buildMouseEvent (type, ...propertiesObjects) { + let properties = extend({ + bubbles: true, + cancelable: true + }, ...propertiesObjects) + + if (properties.detail == null) { + properties.detail = 1 + } + + let event = new MouseEvent(type, properties) + if (properties.which != null) { + Object.defineProperty(event, 'which', { + get: function () { + return properties.which + } + }) + } + if (properties.target != null) { + Object.defineProperty(event, 'target', { + get: function () { + return properties.target + } + }) + Object.defineProperty(event, 'srcObject', { + get: function () { + return properties.target + } + }) + } + return event + } + + function clientCoordinatesForScreenPosition (screenPosition) { + let clientX, clientY, positionOffset, scrollViewClientRect + positionOffset = wrapperNode.pixelPositionForScreenPosition(screenPosition) + scrollViewClientRect = componentNode.querySelector('.scroll-view').getBoundingClientRect() + clientX = scrollViewClientRect.left + positionOffset.left - wrapperNode.getScrollLeft() + clientY = scrollViewClientRect.top + positionOffset.top - wrapperNode.getScrollTop() + return { + clientX: clientX, + clientY: clientY + } + } + + function clientCoordinatesForScreenRowInGutter (screenRow) { + let clientX, clientY, gutterClientRect, positionOffset + positionOffset = wrapperNode.pixelPositionForScreenPosition([screenRow, Infinity]) + gutterClientRect = componentNode.querySelector('.gutter').getBoundingClientRect() + clientX = gutterClientRect.left + positionOffset.left - wrapperNode.getScrollLeft() + clientY = gutterClientRect.top + positionOffset.top - wrapperNode.getScrollTop() + return { + clientX: clientX, + clientY: clientY + } + } + + function lineAndLineNumberHaveClass (screenRow, klass) { + return lineHasClass(screenRow, klass) && lineNumberHasClass(screenRow, klass) + } + + function lineNumberHasClass (screenRow, klass) { + return component.lineNumberNodeForScreenRow(screenRow).classList.contains(klass) + } + + function lineNumberForBufferRowHasClass (bufferRow, klass) { + let screenRow + screenRow = editor.screenRowForBufferRow(bufferRow) + return component.lineNumberNodeForScreenRow(screenRow).classList.contains(klass) + } + + function lineHasClass (screenRow, klass) { + return component.lineNodeForScreenRow(screenRow).classList.contains(klass) + } + + function getLeafNodes (node) { + if (node.children.length > 0) { + return flatten(toArray(node.children).map(getLeafNodes)) + } else { + return [node] + } + } + + function conditionPromise (condition) { + let timeoutError = new Error("Timed out waiting on condition") + Error.captureStackTrace(timeoutError, conditionPromise) + + return new Promise(function (resolve, reject) { + let interval = window.setInterval.originalValue.apply(window, [function () { + if (condition()) { + window.clearInterval(interval) + window.clearTimeout(timeout) + resolve() + } + }, 100]) + let timeout = window.setTimeout.originalValue.apply(window, [function () { + window.clearInterval(interval) + reject(timeoutError) + }, 5000]) + }) + } + + function decorationsUpdatedPromise(editor) { + return new Promise(function (resolve) { + let disposable = editor.onDidUpdateDecorations(function () { + disposable.dispose() + resolve() + }) + }) + } +}) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 1daeb34e98c..2cebda10b1f 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1,5153 +1,75 @@ /** @babel */ import {it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise} from './async-spec-helpers' -import Grim from 'grim' -import TextEditor from '../src/text-editor' -import TextEditorElement from '../src/text-editor-element' -import _, {extend, flatten, last, toArray} from 'underscore-plus' -const NBSP = String.fromCharCode(160) -const TILE_SIZE = 3 - -describe('TextEditorComponent', function () { - let charWidth, component, componentNode, contentNode, editor, - horizontalScrollbarNode, lineHeightInPixels, tileHeightInPixels, - verticalScrollbarNode, wrapperNode, animationFrameRequests - - function runAnimationFrames (runFollowupFrames) { - if (runFollowupFrames) { - let fn - while (fn = animationFrameRequests.shift()) fn() - } else { - const requests = animationFrameRequests.slice() - animationFrameRequests = [] - for (let fn of requests) fn() - } - } - - beforeEach(async function () { - animationFrameRequests = [] - spyOn(window, 'requestAnimationFrame').andCallFake(function (fn) { animationFrameRequests.push(fn) }) - jasmine.useMockClock() - - await atom.packages.activatePackage('language-javascript') - editor = await atom.workspace.open('sample.js') - editor.update({autoHeight: true}) - - contentNode = document.querySelector('#jasmine-content') - contentNode.style.width = '1000px' - - wrapperNode = new TextEditorElement() - wrapperNode.tileSize = TILE_SIZE - wrapperNode.initialize(editor, atom) - wrapperNode.setUpdatedSynchronously(false) - jasmine.attachToDOM(wrapperNode) - - component = wrapperNode.component - component.setFontFamily('monospace') - component.setLineHeight(1.3) - component.setFontSize(20) - - lineHeightInPixels = editor.getLineHeightInPixels() - tileHeightInPixels = TILE_SIZE * lineHeightInPixels - charWidth = editor.getDefaultCharWidth() - - componentNode = component.getDomNode() - verticalScrollbarNode = componentNode.querySelector('.vertical-scrollbar') - horizontalScrollbarNode = componentNode.querySelector('.horizontal-scrollbar') - - component.measureDimensions() - runAnimationFrames(true) - }) - - afterEach(function () { - contentNode.style.width = '' - }) - - describe('async updates', function () { - it('handles corrupted state gracefully', function () { - editor.insertNewline() - component.presenter.startRow = -1 - component.presenter.endRow = 9999 - runAnimationFrames() // assert an update does occur - }) - - it('does not update when an animation frame was requested but the component got destroyed before its delivery', function () { - editor.setText('You should not see this update.') - component.destroy() - - runAnimationFrames() - - expect(component.lineNodeForScreenRow(0).textContent).not.toBe('You should not see this update.') - }) - }) - - describe('line rendering', function () { - function expectTileContainsRow (tileNode, screenRow, {top}) { - let lineNode = tileNode.querySelector('[data-screen-row="' + screenRow + '"]') - let text = editor.lineTextForScreenRow(screenRow) - expect(lineNode.offsetTop).toBe(top) - if (text === '') { - expect(lineNode.textContent).toBe(' ') - } else { - expect(lineNode.textContent).toBe(text) - } - } - - it('gives the lines container the same height as the wrapper node', function () { - let linesNode = componentNode.querySelector('.lines') - wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - expect(linesNode.getBoundingClientRect().height).toBe(6.5 * lineHeightInPixels) - wrapperNode.style.height = 3.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - - runAnimationFrames() - - expect(linesNode.getBoundingClientRect().height).toBe(3.5 * lineHeightInPixels) - }) - - it('renders higher tiles in front of lower ones', function () { - wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - - runAnimationFrames() - - let tilesNodes = component.tileNodesForLines() - expect(tilesNodes[0].style.zIndex).toBe('2') - expect(tilesNodes[1].style.zIndex).toBe('1') - expect(tilesNodes[2].style.zIndex).toBe('0') - verticalScrollbarNode.scrollTop = 1 * lineHeightInPixels - verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - runAnimationFrames(true) - - tilesNodes = component.tileNodesForLines() - expect(tilesNodes[0].style.zIndex).toBe('3') - expect(tilesNodes[1].style.zIndex).toBe('2') - expect(tilesNodes[2].style.zIndex).toBe('1') - expect(tilesNodes[3].style.zIndex).toBe('0') - }) - - it('renders the currently-visible lines in a tiled fashion', function () { - wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - - runAnimationFrames() - - let tilesNodes = component.tileNodesForLines() - expect(tilesNodes.length).toBe(3) - - expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, 0px, 0px)') - expect(tilesNodes[0].querySelectorAll('.line').length).toBe(TILE_SIZE) - expectTileContainsRow(tilesNodes[0], 0, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[0], 1, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[0], 2, { - top: 2 * lineHeightInPixels - }) - - expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels) + 'px, 0px)') - expect(tilesNodes[1].querySelectorAll('.line').length).toBe(TILE_SIZE) - expectTileContainsRow(tilesNodes[1], 3, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[1], 4, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[1], 5, { - top: 2 * lineHeightInPixels - }) - - expect(tilesNodes[2].style['-webkit-transform']).toBe('translate3d(0px, ' + (2 * tileHeightInPixels) + 'px, 0px)') - expect(tilesNodes[2].querySelectorAll('.line').length).toBe(TILE_SIZE) - expectTileContainsRow(tilesNodes[2], 6, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[2], 7, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[2], 8, { - top: 2 * lineHeightInPixels - }) - - expect(component.lineNodeForScreenRow(9)).toBeUndefined() - - verticalScrollbarNode.scrollTop = TILE_SIZE * lineHeightInPixels + 5 - verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - runAnimationFrames(true) - - tilesNodes = component.tileNodesForLines() - expect(component.lineNodeForScreenRow(2)).toBeUndefined() - expect(tilesNodes.length).toBe(3) - - expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, ' + (0 * tileHeightInPixels - 5) + 'px, 0px)') - expect(tilesNodes[0].querySelectorAll('.line').length).toBe(TILE_SIZE) - expectTileContainsRow(tilesNodes[0], 3, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[0], 4, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[0], 5, { - top: 2 * lineHeightInPixels - }) - - expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels - 5) + 'px, 0px)') - expect(tilesNodes[1].querySelectorAll('.line').length).toBe(TILE_SIZE) - expectTileContainsRow(tilesNodes[1], 6, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[1], 7, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[1], 8, { - top: 2 * lineHeightInPixels - }) - - expect(tilesNodes[2].style['-webkit-transform']).toBe('translate3d(0px, ' + (2 * tileHeightInPixels - 5) + 'px, 0px)') - expect(tilesNodes[2].querySelectorAll('.line').length).toBe(TILE_SIZE) - expectTileContainsRow(tilesNodes[2], 9, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[2], 10, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[2], 11, { - top: 2 * lineHeightInPixels - }) - }) - - it('updates the top position of subsequent tiles when lines are inserted or removed', function () { - wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - editor.getBuffer().deleteRows(0, 1) - - runAnimationFrames() - - let tilesNodes = component.tileNodesForLines() - expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, 0px, 0px)') - expectTileContainsRow(tilesNodes[0], 0, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[0], 1, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[0], 2, { - top: 2 * lineHeightInPixels - }) - - expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels) + 'px, 0px)') - expectTileContainsRow(tilesNodes[1], 3, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[1], 4, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[1], 5, { - top: 2 * lineHeightInPixels - }) - - editor.getBuffer().insert([0, 0], '\n\n') - - runAnimationFrames() - - tilesNodes = component.tileNodesForLines() - expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, 0px, 0px)') - expectTileContainsRow(tilesNodes[0], 0, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[0], 1, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[0], 2, { - top: 2 * lineHeightInPixels - }) - - expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels) + 'px, 0px)') - expectTileContainsRow(tilesNodes[1], 3, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[1], 4, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[1], 5, { - top: 2 * lineHeightInPixels - }) - - expect(tilesNodes[2].style['-webkit-transform']).toBe('translate3d(0px, ' + (2 * tileHeightInPixels) + 'px, 0px)') - expectTileContainsRow(tilesNodes[2], 6, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[2], 7, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[2], 8, { - top: 2 * lineHeightInPixels - }) - }) - - it('updates the lines when lines are inserted or removed above the rendered row range', function () { - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - verticalScrollbarNode.scrollTop = 5 * lineHeightInPixels - verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - runAnimationFrames(true) - - let buffer = editor.getBuffer() - buffer.insert([0, 0], '\n\n') - - runAnimationFrames() - - expect(component.lineNodeForScreenRow(3).textContent).toBe(editor.lineTextForScreenRow(3)) - buffer.delete([[0, 0], [3, 0]]) - - runAnimationFrames() - - expect(component.lineNodeForScreenRow(3).textContent).toBe(editor.lineTextForScreenRow(3)) - }) - - it('updates the top position of lines when the line height changes', function () { - let initialLineHeightInPixels = editor.getLineHeightInPixels() - - component.setLineHeight(2) - - runAnimationFrames() - - let newLineHeightInPixels = editor.getLineHeightInPixels() - expect(newLineHeightInPixels).not.toBe(initialLineHeightInPixels) - expect(component.lineNodeForScreenRow(1).offsetTop).toBe(1 * newLineHeightInPixels) - }) - - it('updates the top position of lines when the font size changes', function () { - let initialLineHeightInPixels = editor.getLineHeightInPixels() - component.setFontSize(10) - - runAnimationFrames() - - let newLineHeightInPixels = editor.getLineHeightInPixels() - expect(newLineHeightInPixels).not.toBe(initialLineHeightInPixels) - expect(component.lineNodeForScreenRow(1).offsetTop).toBe(1 * newLineHeightInPixels) - }) - - it('renders the .lines div at the full height of the editor if there are not enough lines to scroll vertically', function () { - editor.setText('') - wrapperNode.style.height = '300px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - let linesNode = componentNode.querySelector('.lines') - expect(linesNode.offsetHeight).toBe(300) - }) - - it('assigns the width of each line so it extends across the full width of the editor', function () { - let gutterWidth = componentNode.querySelector('.gutter').offsetWidth - let scrollViewNode = componentNode.querySelector('.scroll-view') - let lineNodes = Array.from(componentNode.querySelectorAll('.line')) - - componentNode.style.width = gutterWidth + (30 * charWidth) + 'px' - component.measureDimensions() - - runAnimationFrames() - - expect(wrapperNode.getScrollWidth()).toBeGreaterThan(scrollViewNode.offsetWidth) - let editorFullWidth = wrapperNode.getScrollWidth() + wrapperNode.getVerticalScrollbarWidth() - for (let lineNode of lineNodes) { - expect(lineNode.getBoundingClientRect().width).toBe(editorFullWidth) - } - - componentNode.style.width = gutterWidth + wrapperNode.getScrollWidth() + 100 + 'px' - component.measureDimensions() - - runAnimationFrames() - - let scrollViewWidth = scrollViewNode.offsetWidth - for (let lineNode of lineNodes) { - expect(lineNode.getBoundingClientRect().width).toBe(scrollViewWidth) - } - }) - - it('renders a placeholder space on empty lines when no line-ending character is defined', function () { - editor.update({showInvisibles: false}) - expect(component.lineNodeForScreenRow(10).textContent).toBe(' ') - }) - - it('gives the lines and tiles divs the same background color as the editor to improve GPU performance', async function () { - let linesNode = componentNode.querySelector('.lines') - let backgroundColor = getComputedStyle(wrapperNode).backgroundColor - - expect(linesNode.style.backgroundColor).toBe(backgroundColor) - for (let tileNode of component.tileNodesForLines()) { - expect(tileNode.style.backgroundColor).toBe(backgroundColor) - } - - wrapperNode.style.backgroundColor = 'rgb(255, 0, 0)' - runAnimationFrames(true) - - expect(linesNode.style.backgroundColor).toBe('rgb(255, 0, 0)') - for (let tileNode of component.tileNodesForLines()) { - expect(tileNode.style.backgroundColor).toBe('rgb(255, 0, 0)') - } - }) - - it('applies .leading-whitespace for lines with leading spaces and/or tabs', function () { - editor.setText(' a') - - runAnimationFrames() - - let leafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) - expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(true) - expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(false) - - editor.setText('\ta') - runAnimationFrames() - - leafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) - expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(true) - expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(false) - }) - - it('applies .trailing-whitespace for lines with trailing spaces and/or tabs', function () { - editor.setText(' ') - runAnimationFrames() - - let leafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) - expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(true) - expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(false) - - editor.setText('\t') - runAnimationFrames() - - leafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) - expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(true) - expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(false) - editor.setText('a ') - runAnimationFrames() - - leafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) - expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(true) - expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(false) - editor.setText('a\t') - runAnimationFrames() - - leafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) - expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(true) - expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(false) - }) - - it('keeps rebuilding lines when continuous reflow is on', function () { - wrapperNode.setContinuousReflow(true) - let oldLineNode = componentNode.querySelectorAll('.line')[1] - - while (true) { - runAnimationFrames() - if (componentNode.querySelectorAll('.line')[1] !== oldLineNode) break - } - }) - - describe('when showInvisibles is enabled', function () { - const invisibles = { - eol: 'E', - space: 'S', - tab: 'T', - cr: 'C' - } - - beforeEach(function () { - editor.update({ - showInvisibles: true, - invisibles: invisibles - }) - runAnimationFrames() - }) - - it('re-renders the lines when the showInvisibles config option changes', function () { - editor.setText(' a line with tabs\tand spaces \n') - runAnimationFrames() - - expect(component.lineNodeForScreenRow(0).textContent).toBe('' + invisibles.space + 'a line with tabs' + invisibles.tab + 'and spaces' + invisibles.space + invisibles.eol) - - editor.update({showInvisibles: false}) - runAnimationFrames() - - expect(component.lineNodeForScreenRow(0).textContent).toBe(' a line with tabs and spaces ') - - editor.update({showInvisibles: true}) - runAnimationFrames() - - expect(component.lineNodeForScreenRow(0).textContent).toBe('' + invisibles.space + 'a line with tabs' + invisibles.tab + 'and spaces' + invisibles.space + invisibles.eol) - }) - - it('displays leading/trailing spaces, tabs, and newlines as visible characters', function () { - editor.setText(' a line with tabs\tand spaces \n') - - runAnimationFrames() - - expect(component.lineNodeForScreenRow(0).textContent).toBe('' + invisibles.space + 'a line with tabs' + invisibles.tab + 'and spaces' + invisibles.space + invisibles.eol) - - let leafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) - expect(leafNodes[0].classList.contains('invisible-character')).toBe(true) - expect(leafNodes[leafNodes.length - 1].classList.contains('invisible-character')).toBe(true) - }) - - it('displays newlines as their own token outside of the other tokens\' scopeDescriptor', function () { - editor.setText('let\n') - runAnimationFrames() - expect(component.lineNodeForScreenRow(0).innerHTML).toBe('let' + invisibles.eol + '') - }) - - it('displays trailing carriage returns using a visible, non-empty value', function () { - editor.setText('a line that ends with a carriage return\r\n') - runAnimationFrames() - expect(component.lineNodeForScreenRow(0).textContent).toBe('a line that ends with a carriage return' + invisibles.cr + invisibles.eol) - }) - - it('renders invisible line-ending characters on empty lines', function () { - expect(component.lineNodeForScreenRow(10).textContent).toBe(invisibles.eol) - }) - - it('renders a placeholder space on empty lines when the line-ending character is an empty string', function () { - editor.update({invisibles: {eol: ''}}) - runAnimationFrames() - expect(component.lineNodeForScreenRow(10).textContent).toBe(' ') - }) - - it('renders an placeholder space on empty lines when the line-ending character is false', function () { - editor.update({invisibles: {eol: false}}) - runAnimationFrames() - expect(component.lineNodeForScreenRow(10).textContent).toBe(' ') - }) - - it('interleaves invisible line-ending characters with indent guides on empty lines', function () { - editor.update({showIndentGuide: true}) - - runAnimationFrames() - - editor.setTabLength(2) - editor.setTextInBufferRange([[10, 0], [11, 0]], '\r\n', { - normalizeLineEndings: false - }) - runAnimationFrames() - expect(component.lineNodeForScreenRow(10).innerHTML).toBe('CE') - - editor.setTabLength(3) - runAnimationFrames() - expect(component.lineNodeForScreenRow(10).innerHTML).toBe('CE') - - editor.setTabLength(1) - runAnimationFrames() - expect(component.lineNodeForScreenRow(10).innerHTML).toBe('CE') - - editor.setTextInBufferRange([[9, 0], [9, Infinity]], ' ') - editor.setTextInBufferRange([[11, 0], [11, Infinity]], ' ') - runAnimationFrames() - expect(component.lineNodeForScreenRow(10).innerHTML).toBe('CE') - }) - - describe('when soft wrapping is enabled', function () { - beforeEach(function () { - editor.setText('a line that wraps \n') - editor.setSoftWrapped(true) - runAnimationFrames() - - componentNode.style.width = 17 * charWidth + wrapperNode.getVerticalScrollbarWidth() + 'px' - component.measureDimensions() - runAnimationFrames() - }) - - it('does not show end of line invisibles at the end of wrapped lines', function () { - expect(component.lineNodeForScreenRow(0).textContent).toBe('a line ') - expect(component.lineNodeForScreenRow(1).textContent).toBe('that wraps' + invisibles.space + invisibles.eol) - }) - }) - }) - - describe('when indent guides are enabled', function () { - beforeEach(function () { - editor.update({showIndentGuide: true}) - runAnimationFrames() - }) - - it('adds an "indent-guide" class to spans comprising the leading whitespace', function () { - let 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) - - let line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2)) - expect(line2LeafNodes[0].textContent).toBe(' ') - expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe(true) - expect(line2LeafNodes[1].textContent).toBe(' ') - expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe(true) - expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe(false) - }) - - it('renders leading whitespace spans with the "indent-guide" class for empty lines', function () { - editor.getBuffer().insert([1, Infinity], '\n') - runAnimationFrames() - - let line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2)) - expect(line2LeafNodes.length).toBe(2) - expect(line2LeafNodes[0].textContent).toBe(' ') - expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe(true) - expect(line2LeafNodes[1].textContent).toBe(' ') - expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe(true) - }) - - it('renders indent guides correctly on lines containing only whitespace', function () { - editor.getBuffer().insert([1, Infinity], '\n ') - runAnimationFrames() - - let line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2)) - expect(line2LeafNodes.length).toBe(3) - expect(line2LeafNodes[0].textContent).toBe(' ') - expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe(true) - expect(line2LeafNodes[1].textContent).toBe(' ') - expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe(true) - expect(line2LeafNodes[2].textContent).toBe(' ') - expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe(true) - }) - - it('renders indent guides correctly on lines containing only whitespace when invisibles are enabled', function () { - editor.update({ - showInvisibles: true, - invisibles: { - space: '-', - eol: 'x' - } - }) - editor.getBuffer().insert([1, Infinity], '\n ') - - runAnimationFrames() - - let line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2)) - expect(line2LeafNodes.length).toBe(4) - expect(line2LeafNodes[0].textContent).toBe('--') - expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe(true) - expect(line2LeafNodes[1].textContent).toBe('--') - expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe(true) - expect(line2LeafNodes[2].textContent).toBe('--') - expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe(true) - expect(line2LeafNodes[3].textContent).toBe('x') - }) - - it('does not render indent guides in trailing whitespace for lines containing non whitespace characters', function () { - editor.getBuffer().setText(' hi ') - - runAnimationFrames() - - let line0LeafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) - expect(line0LeafNodes[0].textContent).toBe(' ') - expect(line0LeafNodes[0].classList.contains('indent-guide')).toBe(true) - expect(line0LeafNodes[1].textContent).toBe(' ') - expect(line0LeafNodes[1].classList.contains('indent-guide')).toBe(false) - }) - - it('updates the indent guides on empty lines preceding an indentation change', function () { - editor.getBuffer().insert([12, 0], '\n') - runAnimationFrames() - - editor.getBuffer().insert([13, 0], ' ') - runAnimationFrames() - - let line12LeafNodes = getLeafNodes(component.lineNodeForScreenRow(12)) - expect(line12LeafNodes[0].textContent).toBe(' ') - expect(line12LeafNodes[0].classList.contains('indent-guide')).toBe(true) - expect(line12LeafNodes[1].textContent).toBe(' ') - expect(line12LeafNodes[1].classList.contains('indent-guide')).toBe(true) - }) - - it('updates the indent guides on empty lines following an indentation change', function () { - editor.getBuffer().insert([12, 2], '\n') - - runAnimationFrames() - - editor.getBuffer().insert([12, 0], ' ') - runAnimationFrames() - - let line13LeafNodes = getLeafNodes(component.lineNodeForScreenRow(13)) - expect(line13LeafNodes[0].textContent).toBe(' ') - expect(line13LeafNodes[0].classList.contains('indent-guide')).toBe(true) - expect(line13LeafNodes[1].textContent).toBe(' ') - expect(line13LeafNodes[1].classList.contains('indent-guide')).toBe(true) - }) - }) - - describe('when indent guides are disabled', function () { - beforeEach(function () { - expect(atom.config.get('editor.showIndentGuide')).toBe(false) - }) - - it('does not render indent guides on lines containing only whitespace', function () { - editor.getBuffer().insert([1, Infinity], '\n ') - - runAnimationFrames() - - let line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2)) - expect(line2LeafNodes.length).toBe(1) - expect(line2LeafNodes[0].textContent).toBe(' ') - expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe(false) - }) - }) - - describe('when the buffer contains null bytes', function () { - it('excludes the null byte from character measurement', function () { - editor.setText('a\0b') - runAnimationFrames() - expect(wrapperNode.pixelPositionForScreenPosition([0, Infinity]).left).toEqual(2 * charWidth) - }) - }) - - describe('when there is a fold', function () { - it('renders a fold marker on the folded line', function () { - let foldedLineNode = component.lineNodeForScreenRow(4) - expect(foldedLineNode.querySelector('.fold-marker')).toBeFalsy() - editor.foldBufferRow(4) - - runAnimationFrames() - - foldedLineNode = component.lineNodeForScreenRow(4) - expect(foldedLineNode.querySelector('.fold-marker')).toBeTruthy() - editor.unfoldBufferRow(4) - - runAnimationFrames() - - foldedLineNode = component.lineNodeForScreenRow(4) - expect(foldedLineNode.querySelector('.fold-marker')).toBeFalsy() - }) - }) - }) - - describe('gutter rendering', function () { - function expectTileContainsRow (tileNode, screenRow, {top, text}) { - let lineNode = tileNode.querySelector('[data-screen-row="' + screenRow + '"]') - expect(lineNode.offsetTop).toBe(top) - expect(lineNode.textContent).toBe(text) - } - - it('renders higher tiles in front of lower ones', function () { - wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames(true) - - let tilesNodes = component.tileNodesForLineNumbers() - expect(tilesNodes[0].style.zIndex).toBe('2') - expect(tilesNodes[1].style.zIndex).toBe('1') - expect(tilesNodes[2].style.zIndex).toBe('0') - verticalScrollbarNode.scrollTop = 1 * lineHeightInPixels - verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - runAnimationFrames(true) - - tilesNodes = component.tileNodesForLineNumbers() - expect(tilesNodes[0].style.zIndex).toBe('3') - expect(tilesNodes[1].style.zIndex).toBe('2') - expect(tilesNodes[2].style.zIndex).toBe('1') - expect(tilesNodes[3].style.zIndex).toBe('0') - }) - - it('gives the line numbers container the same height as the wrapper node', function () { - let linesNode = componentNode.querySelector('.line-numbers') - wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - - runAnimationFrames() - - expect(linesNode.getBoundingClientRect().height).toBe(6.5 * lineHeightInPixels) - wrapperNode.style.height = 3.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - - runAnimationFrames() - - expect(linesNode.getBoundingClientRect().height).toBe(3.5 * lineHeightInPixels) - }) - - it('renders the currently-visible line numbers in a tiled fashion', function () { - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - let tilesNodes = component.tileNodesForLineNumbers() - expect(tilesNodes.length).toBe(3) - - expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, 0px, 0px)') - expect(tilesNodes[0].querySelectorAll('.line-number').length).toBe(3) - expectTileContainsRow(tilesNodes[0], 0, { - top: lineHeightInPixels * 0, - text: '' + NBSP + '1' - }) - expectTileContainsRow(tilesNodes[0], 1, { - top: lineHeightInPixels * 1, - text: '' + NBSP + '2' - }) - expectTileContainsRow(tilesNodes[0], 2, { - top: lineHeightInPixels * 2, - text: '' + NBSP + '3' - }) - - expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels) + 'px, 0px)') - expect(tilesNodes[1].querySelectorAll('.line-number').length).toBe(3) - expectTileContainsRow(tilesNodes[1], 3, { - top: lineHeightInPixels * 0, - text: '' + NBSP + '4' - }) - expectTileContainsRow(tilesNodes[1], 4, { - top: lineHeightInPixels * 1, - text: '' + NBSP + '5' - }) - expectTileContainsRow(tilesNodes[1], 5, { - top: lineHeightInPixels * 2, - text: '' + NBSP + '6' - }) - - expect(tilesNodes[2].style['-webkit-transform']).toBe('translate3d(0px, ' + (2 * tileHeightInPixels) + 'px, 0px)') - expect(tilesNodes[2].querySelectorAll('.line-number').length).toBe(3) - expectTileContainsRow(tilesNodes[2], 6, { - top: lineHeightInPixels * 0, - text: '' + NBSP + '7' - }) - expectTileContainsRow(tilesNodes[2], 7, { - top: lineHeightInPixels * 1, - text: '' + NBSP + '8' - }) - expectTileContainsRow(tilesNodes[2], 8, { - top: lineHeightInPixels * 2, - text: '' + NBSP + '9' - }) - verticalScrollbarNode.scrollTop = TILE_SIZE * lineHeightInPixels + 5 - verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - runAnimationFrames(true) - - tilesNodes = component.tileNodesForLineNumbers() - expect(component.lineNumberNodeForScreenRow(2)).toBeUndefined() - expect(tilesNodes.length).toBe(3) - - expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, ' + (0 * tileHeightInPixels - 5) + 'px, 0px)') - expect(tilesNodes[0].querySelectorAll('.line-number').length).toBe(TILE_SIZE) - expectTileContainsRow(tilesNodes[0], 3, { - top: lineHeightInPixels * 0, - text: '' + NBSP + '4' - }) - expectTileContainsRow(tilesNodes[0], 4, { - top: lineHeightInPixels * 1, - text: '' + NBSP + '5' - }) - expectTileContainsRow(tilesNodes[0], 5, { - top: lineHeightInPixels * 2, - text: '' + NBSP + '6' - }) - - expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels - 5) + 'px, 0px)') - expect(tilesNodes[1].querySelectorAll('.line-number').length).toBe(TILE_SIZE) - expectTileContainsRow(tilesNodes[1], 6, { - top: 0 * lineHeightInPixels, - text: '' + NBSP + '7' - }) - expectTileContainsRow(tilesNodes[1], 7, { - top: 1 * lineHeightInPixels, - text: '' + NBSP + '8' - }) - expectTileContainsRow(tilesNodes[1], 8, { - top: 2 * lineHeightInPixels, - text: '' + NBSP + '9' - }) - - expect(tilesNodes[2].style['-webkit-transform']).toBe('translate3d(0px, ' + (2 * tileHeightInPixels - 5) + 'px, 0px)') - expect(tilesNodes[2].querySelectorAll('.line-number').length).toBe(TILE_SIZE) - expectTileContainsRow(tilesNodes[2], 9, { - top: 0 * lineHeightInPixels, - text: '10' - }) - expectTileContainsRow(tilesNodes[2], 10, { - top: 1 * lineHeightInPixels, - text: '11' - }) - expectTileContainsRow(tilesNodes[2], 11, { - top: 2 * lineHeightInPixels, - text: '12' - }) - }) - - it('updates the translation of subsequent line numbers when lines are inserted or removed', function () { - editor.getBuffer().insert([0, 0], '\n\n') - runAnimationFrames() - - let lineNumberNodes = componentNode.querySelectorAll('.line-number') - expect(component.lineNumberNodeForScreenRow(0).offsetTop).toBe(0 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(1).offsetTop).toBe(1 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(2).offsetTop).toBe(2 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(3).offsetTop).toBe(0 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(4).offsetTop).toBe(1 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(5).offsetTop).toBe(2 * lineHeightInPixels) - editor.getBuffer().insert([0, 0], '\n\n') - - runAnimationFrames() - - expect(component.lineNumberNodeForScreenRow(0).offsetTop).toBe(0 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(1).offsetTop).toBe(1 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(2).offsetTop).toBe(2 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(3).offsetTop).toBe(0 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(4).offsetTop).toBe(1 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(5).offsetTop).toBe(2 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(6).offsetTop).toBe(0 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(7).offsetTop).toBe(1 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(8).offsetTop).toBe(2 * lineHeightInPixels) - }) - - it('renders • characters for soft-wrapped lines', function () { - editor.setSoftWrapped(true) - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 30 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - - runAnimationFrames() - - expect(componentNode.querySelectorAll('.line-number').length).toBe(9 + 1) - 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 + '•') - expect(component.lineNumberNodeForScreenRow(6).textContent).toBe('' + NBSP + '4') - expect(component.lineNumberNodeForScreenRow(7).textContent).toBe('' + NBSP + '•') - expect(component.lineNumberNodeForScreenRow(8).textContent).toBe('' + NBSP + '•') - }) - - it('pads line numbers to be right-justified based on the maximum number of line number digits', function () { - const input = []; - for (let i = 1; i <= 100; ++i) { - input.push(i); - } - editor.getBuffer().setText(input.join('\n')) - runAnimationFrames() - - for (let screenRow = 0; screenRow <= 8; ++screenRow) { - expect(component.lineNumberNodeForScreenRow(screenRow).textContent).toBe('' + NBSP + NBSP + (screenRow + 1)) - } - expect(component.lineNumberNodeForScreenRow(99).textContent).toBe('100') - let gutterNode = componentNode.querySelector('.gutter') - let initialGutterWidth = gutterNode.offsetWidth - editor.getBuffer().delete([[1, 0], [2, 0]]) - - runAnimationFrames() - - for (let screenRow = 0; screenRow <= 8; ++screenRow) { - expect(component.lineNumberNodeForScreenRow(screenRow).textContent).toBe('' + NBSP + (screenRow + 1)) - } - expect(gutterNode.offsetWidth).toBeLessThan(initialGutterWidth) - editor.getBuffer().insert([0, 0], '\n\n') - - runAnimationFrames() - - for (let screenRow = 0; screenRow <= 8; ++screenRow) { - expect(component.lineNumberNodeForScreenRow(screenRow).textContent).toBe('' + NBSP + NBSP + (screenRow + 1)) - } - expect(component.lineNumberNodeForScreenRow(99).textContent).toBe('100') - expect(gutterNode.offsetWidth).toBe(initialGutterWidth) - }) - - it('renders the .line-numbers div at the full height of the editor even if it\'s taller than its content', function () { - wrapperNode.style.height = componentNode.offsetHeight + 100 + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - expect(componentNode.querySelector('.line-numbers').offsetHeight).toBe(componentNode.offsetHeight) - }) - - it('applies the background color of the gutter or the editor to the line numbers to improve GPU performance', function () { - let gutterNode = componentNode.querySelector('.gutter') - let lineNumbersNode = gutterNode.querySelector('.line-numbers') - let backgroundColor = getComputedStyle(wrapperNode).backgroundColor - expect(lineNumbersNode.style.backgroundColor).toBe(backgroundColor) - for (let tileNode of component.tileNodesForLineNumbers()) { - expect(tileNode.style.backgroundColor).toBe(backgroundColor) - } - - gutterNode.style.backgroundColor = 'rgb(255, 0, 0)' - runAnimationFrames() - - expect(lineNumbersNode.style.backgroundColor).toBe('rgb(255, 0, 0)') - for (let tileNode of component.tileNodesForLineNumbers()) { - expect(tileNode.style.backgroundColor).toBe('rgb(255, 0, 0)') - } - }) - - it('hides or shows the gutter based on the "::isLineNumberGutterVisible" property on the model and the global "editor.showLineNumbers" config setting', function () { - expect(component.gutterContainerComponent.getLineNumberGutterComponent() != null).toBe(true) - editor.setLineNumberGutterVisible(false) - runAnimationFrames() - - expect(componentNode.querySelector('.gutter').style.display).toBe('none') - editor.update({showLineNumbers: false}) - runAnimationFrames() - - expect(componentNode.querySelector('.gutter').style.display).toBe('none') - editor.setLineNumberGutterVisible(true) - runAnimationFrames() - - expect(componentNode.querySelector('.gutter').style.display).toBe('none') - editor.update({showLineNumbers: true}) - runAnimationFrames() - - expect(componentNode.querySelector('.gutter').style.display).toBe('') - expect(component.lineNumberNodeForScreenRow(3) != null).toBe(true) - }) - - it('keeps rebuilding line numbers when continuous reflow is on', function () { - wrapperNode.setContinuousReflow(true) - let oldLineNode = componentNode.querySelectorAll('.line-number')[1] - - while (true) { - runAnimationFrames() - if (componentNode.querySelectorAll('.line-number')[1] !== oldLineNode) break - } - }) - - describe('fold decorations', function () { - describe('rendering fold decorations', function () { - it('adds the foldable class to line numbers when the line is foldable', function () { - expect(lineNumberHasClass(0, 'foldable')).toBe(true) - expect(lineNumberHasClass(1, 'foldable')).toBe(true) - expect(lineNumberHasClass(2, 'foldable')).toBe(false) - expect(lineNumberHasClass(3, 'foldable')).toBe(false) - expect(lineNumberHasClass(4, 'foldable')).toBe(true) - expect(lineNumberHasClass(5, 'foldable')).toBe(false) - }) - - it('updates the foldable class on the correct line numbers when the foldable positions change', function () { - editor.getBuffer().insert([0, 0], '\n') - runAnimationFrames() - - expect(lineNumberHasClass(0, 'foldable')).toBe(false) - expect(lineNumberHasClass(1, 'foldable')).toBe(true) - expect(lineNumberHasClass(2, 'foldable')).toBe(true) - expect(lineNumberHasClass(3, 'foldable')).toBe(false) - expect(lineNumberHasClass(4, 'foldable')).toBe(false) - expect(lineNumberHasClass(5, 'foldable')).toBe(true) - expect(lineNumberHasClass(6, 'foldable')).toBe(false) - }) - - it('updates the foldable class on a line number that becomes foldable', function () { - expect(lineNumberHasClass(11, 'foldable')).toBe(false) - editor.getBuffer().insert([11, 44], '\n fold me') - runAnimationFrames() - expect(lineNumberHasClass(11, 'foldable')).toBe(true) - editor.undo() - runAnimationFrames() - expect(lineNumberHasClass(11, 'foldable')).toBe(false) - }) - - it('adds, updates and removes the folded class on the correct line number componentNodes', function () { - editor.foldBufferRow(4) - runAnimationFrames() - - expect(lineNumberHasClass(4, 'folded')).toBe(true) - - editor.getBuffer().insert([0, 0], '\n') - runAnimationFrames() - - expect(lineNumberHasClass(4, 'folded')).toBe(false) - expect(lineNumberHasClass(5, 'folded')).toBe(true) - - editor.unfoldBufferRow(5) - runAnimationFrames() - - expect(lineNumberHasClass(5, 'folded')).toBe(false) - }) - - describe('when soft wrapping is enabled', function () { - beforeEach(function () { - editor.setSoftWrapped(true) - runAnimationFrames() - componentNode.style.width = 20 * charWidth + wrapperNode.getVerticalScrollbarWidth() + 'px' - component.measureDimensions() - runAnimationFrames() - }) - - it('does not add the foldable class for soft-wrapped lines', function () { - expect(lineNumberHasClass(0, 'foldable')).toBe(true) - expect(lineNumberHasClass(1, 'foldable')).toBe(false) - }) - - it('does not add the folded class for soft-wrapped lines that contain a fold', function () { - editor.foldBufferRange([[3, 19], [3, 21]]) - runAnimationFrames() - - expect(lineNumberHasClass(11, 'folded')).toBe(true) - expect(lineNumberHasClass(12, 'folded')).toBe(false) - }) - }) - }) - - describe('mouse interactions with fold indicators', function () { - let gutterNode - - function buildClickEvent (target) { - return buildMouseEvent('click', { - target: target - }) - } - - beforeEach(function () { - gutterNode = componentNode.querySelector('.gutter') - }) - - describe('when the component is destroyed', function () { - it('stops listening for folding events', function () { - let lineNumber, target - component.destroy() - lineNumber = component.lineNumberNodeForScreenRow(1) - target = lineNumber.querySelector('.icon-right') - target.dispatchEvent(buildClickEvent(target)) - }) - }) - - it('folds and unfolds the block represented by the fold indicator when clicked', function () { - expect(lineNumberHasClass(1, 'folded')).toBe(false) - - let lineNumber = component.lineNumberNodeForScreenRow(1) - let target = lineNumber.querySelector('.icon-right') - - target.dispatchEvent(buildClickEvent(target)) - - runAnimationFrames() - - expect(lineNumberHasClass(1, 'folded')).toBe(true) - lineNumber = component.lineNumberNodeForScreenRow(1) - target = lineNumber.querySelector('.icon-right') - target.dispatchEvent(buildClickEvent(target)) - - runAnimationFrames() - - expect(lineNumberHasClass(1, 'folded')).toBe(false) - }) - - it('unfolds all the free-form folds intersecting the buffer row when clicked', function () { - expect(lineNumberHasClass(3, 'foldable')).toBe(false) - - editor.foldBufferRange([[3, 4], [5, 4]]) - editor.foldBufferRange([[5, 5], [8, 10]]) - runAnimationFrames() - expect(lineNumberHasClass(3, 'folded')).toBe(true) - expect(lineNumberHasClass(5, 'folded')).toBe(false) - - let lineNumber = component.lineNumberNodeForScreenRow(3) - let target = lineNumber.querySelector('.icon-right') - target.dispatchEvent(buildClickEvent(target)) - runAnimationFrames() - expect(lineNumberHasClass(3, 'folded')).toBe(false) - expect(lineNumberHasClass(5, 'folded')).toBe(true) - - editor.setSoftWrapped(true) - componentNode.style.width = 20 * charWidth + wrapperNode.getVerticalScrollbarWidth() + 'px' - component.measureDimensions() - runAnimationFrames() - editor.foldBufferRange([[3, 19], [3, 21]]) // fold starting on a soft-wrapped portion of the line - runAnimationFrames() - expect(lineNumberHasClass(11, 'folded')).toBe(true) - - lineNumber = component.lineNumberNodeForScreenRow(11) - target = lineNumber.querySelector('.icon-right') - target.dispatchEvent(buildClickEvent(target)) - runAnimationFrames() - expect(lineNumberHasClass(11, 'folded')).toBe(false) - }) - - it('does not fold when the line number componentNode is clicked', function () { - let lineNumber = component.lineNumberNodeForScreenRow(1) - lineNumber.dispatchEvent(buildClickEvent(lineNumber)) - waits(100) - runs(function () { - expect(lineNumberHasClass(1, 'folded')).toBe(false) - }) - }) - }) - }) - }) - - describe('cursor rendering', function () { - it('renders the currently visible cursors', function () { - let cursor1 = editor.getLastCursor() - cursor1.setScreenPosition([0, 5], { - autoscroll: false - }) - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 20 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - let cursorNodes = componentNode.querySelectorAll('.cursor') - expect(cursorNodes.length).toBe(1) - expect(cursorNodes[0].offsetHeight).toBe(lineHeightInPixels) - expect(cursorNodes[0].offsetWidth).toBeCloseTo(charWidth, 0) - expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(5 * charWidth)) + 'px, ' + (0 * lineHeightInPixels) + 'px)') - let cursor2 = editor.addCursorAtScreenPosition([8, 11], { - autoscroll: false - }) - let cursor3 = editor.addCursorAtScreenPosition([4, 10], { - autoscroll: false - }) - runAnimationFrames() - - cursorNodes = componentNode.querySelectorAll('.cursor') - expect(cursorNodes.length).toBe(2) - expect(cursorNodes[0].offsetTop).toBe(0) - expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(5 * charWidth)) + 'px, ' + (0 * lineHeightInPixels) + 'px)') - expect(cursorNodes[1].style['-webkit-transform']).toBe('translate(' + (Math.round(10 * charWidth)) + 'px, ' + (4 * lineHeightInPixels) + 'px)') - verticalScrollbarNode.scrollTop = 4.5 * lineHeightInPixels - verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - runAnimationFrames(true) - - horizontalScrollbarNode.scrollLeft = 3.5 * charWidth - horizontalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - runAnimationFrames(true) - - cursorNodes = componentNode.querySelectorAll('.cursor') - expect(cursorNodes.length).toBe(2) - expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(10 * charWidth - horizontalScrollbarNode.scrollLeft)) + 'px, ' + (4 * lineHeightInPixels - verticalScrollbarNode.scrollTop) + 'px)') - expect(cursorNodes[1].style['-webkit-transform']).toBe('translate(' + (Math.round(11 * charWidth - horizontalScrollbarNode.scrollLeft)) + 'px, ' + (8 * lineHeightInPixels - verticalScrollbarNode.scrollTop) + 'px)') - editor.onDidChangeCursorPosition(cursorMovedListener = jasmine.createSpy('cursorMovedListener')) - cursor3.setScreenPosition([4, 11], { - autoscroll: false - }) - runAnimationFrames() - - expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(11 * charWidth - horizontalScrollbarNode.scrollLeft)) + 'px, ' + (4 * lineHeightInPixels - verticalScrollbarNode.scrollTop) + 'px)') - expect(cursorMovedListener).toHaveBeenCalled() - cursor3.destroy() - runAnimationFrames() - - cursorNodes = componentNode.querySelectorAll('.cursor') - expect(cursorNodes.length).toBe(1) - expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(11 * charWidth - horizontalScrollbarNode.scrollLeft)) + 'px, ' + (8 * lineHeightInPixels - verticalScrollbarNode.scrollTop) + 'px)') - }) - - it('accounts for character widths when positioning cursors', function () { - component.setFontFamily('sans-serif') - editor.setCursorScreenPosition([0, 16]) - runAnimationFrames() - - let cursor = componentNode.querySelector('.cursor') - let cursorRect = cursor.getBoundingClientRect() - let cursorLocationTextNode = component.lineNodeForScreenRow(0).querySelector('.syntax--storage.syntax--type.syntax--function.syntax--js').firstChild - let range = document.createRange() - range.setStart(cursorLocationTextNode, 0) - range.setEnd(cursorLocationTextNode, 1) - let rangeRect = range.getBoundingClientRect() - expect(cursorRect.left).toBeCloseTo(rangeRect.left, 0) - expect(cursorRect.width).toBeCloseTo(rangeRect.width, 0) - }) - - it('accounts for the width of paired characters when positioning cursors', function () { - component.setFontFamily('sans-serif') - editor.setText('he\u0301y') - editor.setCursorBufferPosition([0, 3]) - runAnimationFrames() - - let cursor = componentNode.querySelector('.cursor') - let cursorRect = cursor.getBoundingClientRect() - let cursorLocationTextNode = component.lineNodeForScreenRow(0).querySelector('.syntax--source.syntax--js').childNodes[0] - let range = document.createRange(cursorLocationTextNode) - range.setStart(cursorLocationTextNode, 3) - range.setEnd(cursorLocationTextNode, 4) - let rangeRect = range.getBoundingClientRect() - expect(cursorRect.left).toBeCloseTo(rangeRect.left, 0) - expect(cursorRect.width).toBeCloseTo(rangeRect.width, 0) - }) - - it('positions cursors after the fold-marker when a fold ends the line', function () { - editor.foldBufferRow(0) - runAnimationFrames() - editor.setCursorScreenPosition([0, 30]) - runAnimationFrames() - - let cursorRect = componentNode.querySelector('.cursor').getBoundingClientRect() - let foldMarkerRect = componentNode.querySelector('.fold-marker').getBoundingClientRect() - expect(cursorRect.left).toBeCloseTo(foldMarkerRect.right, 0) - }) - - it('positions cursors correctly after character widths are changed via a stylesheet change', function () { - component.setFontFamily('sans-serif') - editor.setCursorScreenPosition([0, 16]) - runAnimationFrames(true) - - atom.styles.addStyleSheet('.syntax--function.syntax--js {\n font-weight: bold;\n}', { - context: 'atom-text-editor' - }) - runAnimationFrames(true) - - let cursor = componentNode.querySelector('.cursor') - let cursorRect = cursor.getBoundingClientRect() - let cursorLocationTextNode = component.lineNodeForScreenRow(0).querySelector('.syntax--storage.syntax--type.syntax--function.syntax--js').firstChild - let range = document.createRange() - range.setStart(cursorLocationTextNode, 0) - range.setEnd(cursorLocationTextNode, 1) - let rangeRect = range.getBoundingClientRect() - expect(cursorRect.left).toBeCloseTo(rangeRect.left, 0) - expect(cursorRect.width).toBeCloseTo(rangeRect.width, 0) - atom.themes.removeStylesheet('test') - }) - - it('sets the cursor to the default character width at the end of a line', function () { - editor.setCursorScreenPosition([0, Infinity]) - runAnimationFrames() - let cursorNode = componentNode.querySelector('.cursor') - expect(cursorNode.offsetWidth).toBeCloseTo(charWidth, 0) - }) - - it('gives the cursor a non-zero width even if it\'s inside atomic tokens', function () { - editor.setCursorScreenPosition([1, 0]) - runAnimationFrames() - let cursorNode = componentNode.querySelector('.cursor') - expect(cursorNode.offsetWidth).toBeCloseTo(charWidth, 0) - }) - - it('blinks cursors when they are not moving', async function () { - let cursorsNode = componentNode.querySelector('.cursors') - wrapperNode.focus() - runAnimationFrames() - expect(cursorsNode.classList.contains('blink-off')).toBe(false) - advanceClock(component.cursorBlinkPeriod / 2) - runAnimationFrames() - expect(cursorsNode.classList.contains('blink-off')).toBe(true) - advanceClock(component.cursorBlinkPeriod / 2) - runAnimationFrames() - expect(cursorsNode.classList.contains('blink-off')).toBe(false) - editor.moveRight() - runAnimationFrames() - expect(cursorsNode.classList.contains('blink-off')).toBe(false) - advanceClock(component.cursorBlinkResumeDelay) - runAnimationFrames(true) - expect(cursorsNode.classList.contains('blink-off')).toBe(false) - advanceClock(component.cursorBlinkPeriod / 2) - runAnimationFrames() - expect(cursorsNode.classList.contains('blink-off')).toBe(true) - }) - - it('renders cursors that are associated with empty selections', function () { - editor.update({showCursorOnSelection: true}) - editor.setSelectedScreenRange([[0, 4], [4, 6]]) - editor.addCursorAtScreenPosition([6, 8]) - runAnimationFrames() - let cursorNodes = componentNode.querySelectorAll('.cursor') - expect(cursorNodes.length).toBe(2) - expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(6 * charWidth)) + 'px, ' + (4 * lineHeightInPixels) + 'px)') - expect(cursorNodes[1].style['-webkit-transform']).toBe('translate(' + (Math.round(8 * charWidth)) + 'px, ' + (6 * lineHeightInPixels) + 'px)') - }) - - it('does not render cursors that are associated with non-empty selections when showCursorOnSelection is false', function () { - editor.update({showCursorOnSelection: false}) - editor.setSelectedScreenRange([[0, 4], [4, 6]]) - editor.addCursorAtScreenPosition([6, 8]) - runAnimationFrames() - let cursorNodes = componentNode.querySelectorAll('.cursor') - expect(cursorNodes.length).toBe(1) - expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(8 * charWidth)) + 'px, ' + (6 * lineHeightInPixels) + 'px)') - }) - - it('updates cursor positions when the line height changes', function () { - editor.setCursorBufferPosition([1, 10]) - component.setLineHeight(2) - runAnimationFrames() - let cursorNode = componentNode.querySelector('.cursor') - expect(cursorNode.style['-webkit-transform']).toBe('translate(' + (Math.round(10 * editor.getDefaultCharWidth())) + 'px, ' + (editor.getLineHeightInPixels()) + 'px)') - }) - - it('updates cursor positions when the font size changes', function () { - editor.setCursorBufferPosition([1, 10]) - component.setFontSize(10) - runAnimationFrames() - let cursorNode = componentNode.querySelector('.cursor') - expect(cursorNode.style['-webkit-transform']).toBe('translate(' + (Math.round(10 * editor.getDefaultCharWidth())) + 'px, ' + (editor.getLineHeightInPixels()) + 'px)') - }) - - it('updates cursor positions when the font family changes', function () { - editor.setCursorBufferPosition([1, 10]) - component.setFontFamily('sans-serif') - runAnimationFrames() - let cursorNode = componentNode.querySelector('.cursor') - let left = wrapperNode.pixelPositionForScreenPosition([1, 10]).left - expect(cursorNode.style['-webkit-transform']).toBe('translate(' + (Math.round(left)) + 'px, ' + (editor.getLineHeightInPixels()) + 'px)') - }) - }) - - describe('selection rendering', function () { - let scrollViewClientLeft, scrollViewNode - - beforeEach(function () { - scrollViewNode = componentNode.querySelector('.scroll-view') - scrollViewClientLeft = componentNode.querySelector('.scroll-view').getBoundingClientRect().left - }) - - it('renders 1 region for 1-line selections', function () { - editor.setSelectedScreenRange([[1, 6], [1, 10]]) - runAnimationFrames() - - let regions = componentNode.querySelectorAll('.selection .region') - expect(regions.length).toBe(1) - - let regionRect = regions[0].getBoundingClientRect() - expect(regionRect.top).toBe(1 * lineHeightInPixels) - expect(regionRect.height).toBe(1 * lineHeightInPixels) - expect(regionRect.left).toBeCloseTo(scrollViewClientLeft + 6 * charWidth, 0) - expect(regionRect.width).toBeCloseTo(4 * charWidth, 0) - }) - - it('renders 2 regions for 2-line selections', function () { - editor.setSelectedScreenRange([[1, 6], [2, 10]]) - runAnimationFrames() - - let tileNode = component.tileNodesForLines()[0] - let regions = tileNode.querySelectorAll('.selection .region') - expect(regions.length).toBe(2) - - let region1Rect = regions[0].getBoundingClientRect() - expect(region1Rect.top).toBe(1 * lineHeightInPixels) - expect(region1Rect.height).toBe(1 * lineHeightInPixels) - expect(region1Rect.left).toBeCloseTo(scrollViewClientLeft + 6 * charWidth, 0) - expect(region1Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0) - - let region2Rect = regions[1].getBoundingClientRect() - expect(region2Rect.top).toBe(2 * lineHeightInPixels) - expect(region2Rect.height).toBe(1 * lineHeightInPixels) - expect(region2Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0) - expect(region2Rect.width).toBeCloseTo(10 * charWidth, 0) - }) - - it('renders 3 regions per tile for selections with more than 2 lines', function () { - editor.setSelectedScreenRange([[0, 6], [5, 10]]) - runAnimationFrames() - - let region1Rect, region2Rect, region3Rect, regions, tileNode - tileNode = component.tileNodesForLines()[0] - regions = tileNode.querySelectorAll('.selection .region') - expect(regions.length).toBe(3) - - region1Rect = regions[0].getBoundingClientRect() - expect(region1Rect.top).toBe(0) - expect(region1Rect.height).toBe(1 * lineHeightInPixels) - expect(region1Rect.left).toBeCloseTo(scrollViewClientLeft + 6 * charWidth, 0) - expect(region1Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0) - - region2Rect = regions[1].getBoundingClientRect() - expect(region2Rect.top).toBe(1 * lineHeightInPixels) - expect(region2Rect.height).toBe(1 * lineHeightInPixels) - expect(region2Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0) - expect(region2Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0) - - region3Rect = regions[2].getBoundingClientRect() - expect(region3Rect.top).toBe(2 * lineHeightInPixels) - expect(region3Rect.height).toBe(1 * lineHeightInPixels) - expect(region3Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0) - expect(region3Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0) - - tileNode = component.tileNodesForLines()[1] - regions = tileNode.querySelectorAll('.selection .region') - expect(regions.length).toBe(3) - - region1Rect = regions[0].getBoundingClientRect() - expect(region1Rect.top).toBe(3 * lineHeightInPixels) - expect(region1Rect.height).toBe(1 * lineHeightInPixels) - expect(region1Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0) - expect(region1Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0) - - region2Rect = regions[1].getBoundingClientRect() - expect(region2Rect.top).toBe(4 * lineHeightInPixels) - expect(region2Rect.height).toBe(1 * lineHeightInPixels) - expect(region2Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0) - expect(region2Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0) - - region3Rect = regions[2].getBoundingClientRect() - expect(region3Rect.top).toBe(5 * lineHeightInPixels) - expect(region3Rect.height).toBe(1 * lineHeightInPixels) - expect(region3Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0) - expect(region3Rect.width).toBeCloseTo(10 * charWidth, 0) - }) - - it('does not render empty selections', function () { - editor.addSelectionForBufferRange([[2, 2], [2, 2]]) - runAnimationFrames() - expect(editor.getSelections()[0].isEmpty()).toBe(true) - expect(editor.getSelections()[1].isEmpty()).toBe(true) - expect(componentNode.querySelectorAll('.selection').length).toBe(0) - }) - - it('updates selections when the line height changes', function () { - editor.setSelectedBufferRange([[1, 6], [1, 10]]) - component.setLineHeight(2) - runAnimationFrames() - let selectionNode = componentNode.querySelector('.region') - expect(selectionNode.offsetTop).toBe(editor.getLineHeightInPixels()) - }) - - it('updates selections when the font size changes', function () { - editor.setSelectedBufferRange([[1, 6], [1, 10]]) - component.setFontSize(10) - - runAnimationFrames() - - let selectionNode = componentNode.querySelector('.region') - expect(selectionNode.offsetTop).toBe(editor.getLineHeightInPixels()) - expect(selectionNode.offsetLeft).toBeCloseTo(6 * editor.getDefaultCharWidth(), 0) - }) - - it('updates selections when the font family changes', function () { - editor.setSelectedBufferRange([[1, 6], [1, 10]]) - component.setFontFamily('sans-serif') - - runAnimationFrames() - - let selectionNode = componentNode.querySelector('.region') - expect(selectionNode.offsetTop).toBe(editor.getLineHeightInPixels()) - expect(selectionNode.offsetLeft).toBeCloseTo(wrapperNode.pixelPositionForScreenPosition([1, 6]).left, 0) - }) - - it('will flash the selection when flash:true is passed to editor::setSelectedBufferRange', async function () { - editor.setSelectedBufferRange([[1, 6], [1, 10]], { - flash: true - }) - runAnimationFrames() - - let selectionNode = componentNode.querySelector('.selection') - expect(selectionNode.classList.contains('flash')).toBe(true) - - advanceClock(editor.selectionFlashDuration) - - editor.setSelectedBufferRange([[1, 5], [1, 7]], { - flash: true - }) - runAnimationFrames() - - expect(selectionNode.classList.contains('flash')).toBe(true) - }) - }) - - describe('line decoration rendering', async function () { - let decoration, marker - - beforeEach(async function () { - marker = editor.addMarkerLayer({ - maintainHistory: true - }).markBufferRange([[2, 13], [3, 15]], { - invalidate: 'inside' - }) - decoration = editor.decorateMarker(marker, { - type: ['line-number', 'line'], - 'class': 'a' - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - }) - - it('applies line decoration classes to lines and line numbers', async function () { - expect(lineAndLineNumberHaveClass(2, 'a')).toBe(true) - expect(lineAndLineNumberHaveClass(3, 'a')).toBe(true) - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - let marker2 = editor.markBufferRange([[9, 0], [9, 0]]) - editor.decorateMarker(marker2, { - type: ['line-number', 'line'], - 'class': 'b' - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - verticalScrollbarNode.scrollTop = 4.5 * lineHeightInPixels - verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - runAnimationFrames(true) - - expect(lineAndLineNumberHaveClass(9, 'b')).toBe(true) - - editor.foldBufferRow(5) - runAnimationFrames() - - expect(lineAndLineNumberHaveClass(9, 'b')).toBe(false) - expect(lineAndLineNumberHaveClass(6, 'b')).toBe(true) - }) - - it('only applies decorations to screen rows that are spanned by their marker when lines are soft-wrapped', async function () { - editor.setText('a line that wraps, ok') - editor.setSoftWrapped(true) - componentNode.style.width = 16 * charWidth + 'px' - component.measureDimensions() - - runAnimationFrames() - marker.destroy() - marker = editor.markBufferRange([[0, 0], [0, 2]]) - editor.decorateMarker(marker, { - type: ['line-number', 'line'], - 'class': 'b' - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(lineNumberHasClass(0, 'b')).toBe(true) - expect(lineNumberHasClass(1, 'b')).toBe(false) - marker.setBufferRange([[0, 0], [0, Infinity]]) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(lineNumberHasClass(0, 'b')).toBe(true) - expect(lineNumberHasClass(1, 'b')).toBe(true) - }) - - it('updates decorations when markers move', async function () { - expect(lineAndLineNumberHaveClass(1, 'a')).toBe(false) - expect(lineAndLineNumberHaveClass(2, 'a')).toBe(true) - expect(lineAndLineNumberHaveClass(3, 'a')).toBe(true) - expect(lineAndLineNumberHaveClass(4, 'a')).toBe(false) - - editor.getBuffer().insert([0, 0], '\n') - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(lineAndLineNumberHaveClass(2, 'a')).toBe(false) - expect(lineAndLineNumberHaveClass(3, 'a')).toBe(true) - expect(lineAndLineNumberHaveClass(4, 'a')).toBe(true) - expect(lineAndLineNumberHaveClass(5, 'a')).toBe(false) - - marker.setBufferRange([[4, 4], [6, 4]]) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(lineAndLineNumberHaveClass(2, 'a')).toBe(false) - expect(lineAndLineNumberHaveClass(3, 'a')).toBe(false) - expect(lineAndLineNumberHaveClass(4, 'a')).toBe(true) - expect(lineAndLineNumberHaveClass(5, 'a')).toBe(true) - expect(lineAndLineNumberHaveClass(6, 'a')).toBe(true) - expect(lineAndLineNumberHaveClass(7, 'a')).toBe(false) - }) - - it('remove decoration classes when decorations are removed', async function () { - decoration.destroy() - await decorationsUpdatedPromise(editor) - runAnimationFrames() - expect(lineNumberHasClass(1, 'a')).toBe(false) - expect(lineNumberHasClass(2, 'a')).toBe(false) - expect(lineNumberHasClass(3, 'a')).toBe(false) - expect(lineNumberHasClass(4, 'a')).toBe(false) - }) - - it('removes decorations when their marker is invalidated', async function () { - editor.getBuffer().insert([3, 2], 'n') - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(marker.isValid()).toBe(false) - expect(lineAndLineNumberHaveClass(1, 'a')).toBe(false) - expect(lineAndLineNumberHaveClass(2, 'a')).toBe(false) - expect(lineAndLineNumberHaveClass(3, 'a')).toBe(false) - expect(lineAndLineNumberHaveClass(4, 'a')).toBe(false) - editor.undo() - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(marker.isValid()).toBe(true) - expect(lineAndLineNumberHaveClass(1, 'a')).toBe(false) - expect(lineAndLineNumberHaveClass(2, 'a')).toBe(true) - expect(lineAndLineNumberHaveClass(3, 'a')).toBe(true) - expect(lineAndLineNumberHaveClass(4, 'a')).toBe(false) - }) - - it('removes decorations when their marker is destroyed', async function () { - marker.destroy() - await decorationsUpdatedPromise(editor) - runAnimationFrames() - expect(lineNumberHasClass(1, 'a')).toBe(false) - expect(lineNumberHasClass(2, 'a')).toBe(false) - expect(lineNumberHasClass(3, 'a')).toBe(false) - expect(lineNumberHasClass(4, 'a')).toBe(false) - }) - - describe('when the decoration\'s "onlyHead" property is true', async function () { - it('only applies the decoration\'s class to lines containing the marker\'s head', async function () { - editor.decorateMarker(marker, { - type: ['line-number', 'line'], - 'class': 'only-head', - onlyHead: true - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - expect(lineAndLineNumberHaveClass(1, 'only-head')).toBe(false) - expect(lineAndLineNumberHaveClass(2, 'only-head')).toBe(false) - expect(lineAndLineNumberHaveClass(3, 'only-head')).toBe(true) - expect(lineAndLineNumberHaveClass(4, 'only-head')).toBe(false) - }) - }) - - describe('when the decoration\'s "onlyEmpty" property is true', function () { - it('only applies the decoration when its marker is empty', async function () { - editor.decorateMarker(marker, { - type: ['line-number', 'line'], - 'class': 'only-empty', - onlyEmpty: true - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(lineAndLineNumberHaveClass(2, 'only-empty')).toBe(false) - expect(lineAndLineNumberHaveClass(3, 'only-empty')).toBe(false) - - marker.clearTail() - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(lineAndLineNumberHaveClass(2, 'only-empty')).toBe(false) - expect(lineAndLineNumberHaveClass(3, 'only-empty')).toBe(true) - }) - }) - - describe('when the decoration\'s "onlyNonEmpty" property is true', function () { - it('only applies the decoration when its marker is non-empty', async function () { - editor.decorateMarker(marker, { - type: ['line-number', 'line'], - 'class': 'only-non-empty', - onlyNonEmpty: true - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(lineAndLineNumberHaveClass(2, 'only-non-empty')).toBe(true) - expect(lineAndLineNumberHaveClass(3, 'only-non-empty')).toBe(true) - - marker.clearTail() - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(lineAndLineNumberHaveClass(2, 'only-non-empty')).toBe(false) - expect(lineAndLineNumberHaveClass(3, 'only-non-empty')).toBe(false) - }) - }) - }) - - describe('block decorations rendering', function () { - let markerLayer - - function createBlockDecorationBeforeScreenRow(screenRow, {className}) { - let item = document.createElement("div") - item.className = className || "" - let blockDecoration = editor.decorateMarker( - markerLayer.markScreenPosition([screenRow, 0], {invalidate: "never"}), - {type: "block", item: item, position: "before"} - ) - return [item, blockDecoration] - } - - function createBlockDecorationAfterScreenRow(screenRow, {className}) { - let item = document.createElement("div") - item.className = className || "" - let blockDecoration = editor.decorateMarker( - markerLayer.markScreenPosition([screenRow, 0], {invalidate: "never"}), - {type: "block", item: item, position: "after"} - ) - return [item, blockDecoration] - } - - beforeEach(function () { - markerLayer = editor.addMarkerLayer() - wrapperNode.style.height = 5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - }) - - afterEach(function () { - atom.themes.removeStylesheet('test') - }) - - it("renders visible and yet-to-be-measured block decorations, inserting them between the appropriate lines and refreshing them as needed", async function () { - let [item1, blockDecoration1] = createBlockDecorationBeforeScreenRow(0, {className: "decoration-1"}) - let [item2, blockDecoration2] = createBlockDecorationBeforeScreenRow(2, {className: "decoration-2"}) - let [item3, blockDecoration3] = createBlockDecorationBeforeScreenRow(4, {className: "decoration-3"}) - let [item4, blockDecoration4] = createBlockDecorationBeforeScreenRow(7, {className: "decoration-4"}) - let [item5, blockDecoration5] = createBlockDecorationAfterScreenRow(7, {className: "decoration-5"}) - let [item6, blockDecoration6] = createBlockDecorationAfterScreenRow(12, {className: "decoration-6"}) - - atom.styles.addStyleSheet( - `atom-text-editor .decoration-1 { width: 30px; height: 80px; } - atom-text-editor .decoration-2 { width: 30px; height: 40px; } - atom-text-editor .decoration-3 { width: 30px; height: 100px; } - atom-text-editor .decoration-4 { width: 30px; height: 120px; } - atom-text-editor .decoration-5 { width: 30px; height: 42px; } - atom-text-editor .decoration-6 { width: 30px; height: 22px; }`, - {context: 'atom-text-editor'} - ) - runAnimationFrames() - expect(component.getDomNode().querySelectorAll(".line").length).toBe(7) - expect(verticalScrollbarNode.scrollHeight).toBe(editor.getScreenLineCount() * editor.getLineHeightInPixels() + 80 + 40 + 100 + 120 + 42 + 22) - expect(component.tileNodesForLines()[0].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 80 + 40 + "px") - expect(component.tileNodesForLines()[0].style.webkitTransform).toBe("translate3d(0px, 0px, 0px)") - expect(component.tileNodesForLines()[1].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 100 + "px") - expect(component.tileNodesForLines()[1].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight}px, 0px)`) - expect(component.tileNodesForLines()[2].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 120 + 42 + "px") - expect(component.tileNodesForLines()[2].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight + component.tileNodesForLines()[1].offsetHeight}px, 0px)`) - expect(component.getTopmostDOMNode().querySelector(".decoration-1")).toBe(item1) - expect(component.getTopmostDOMNode().querySelector(".decoration-2")).toBe(item2) - expect(component.getTopmostDOMNode().querySelector(".decoration-3")).toBe(item3) - expect(component.getTopmostDOMNode().querySelector(".decoration-4")).toBeNull() - expect(component.getTopmostDOMNode().querySelector(".decoration-5")).toBeNull() - expect(component.getTopmostDOMNode().querySelector(".decoration-6")).toBeNull() - expect(item1.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 0) - expect(item2.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 2 + 80) - expect(item3.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 4 + 80 + 40) - - editor.setCursorScreenPosition([0, 0]) - editor.insertNewline() - blockDecoration1.destroy() - runAnimationFrames() - expect(component.getDomNode().querySelectorAll(".line").length).toBe(7) - expect(verticalScrollbarNode.scrollHeight).toBe(editor.getScreenLineCount() * editor.getLineHeightInPixels() + 40 + 100 + 120 + 42 + 22) - expect(component.tileNodesForLines()[0].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + "px") - expect(component.tileNodesForLines()[0].style.webkitTransform).toBe("translate3d(0px, 0px, 0px)") - expect(component.tileNodesForLines()[1].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 100 + 40 + "px") - expect(component.tileNodesForLines()[1].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight}px, 0px)`) - expect(component.tileNodesForLines()[2].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 120 + 42 + "px") - expect(component.tileNodesForLines()[2].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight + component.tileNodesForLines()[1].offsetHeight}px, 0px)`) - expect(component.getTopmostDOMNode().querySelector(".decoration-1")).toBeNull() - expect(component.getTopmostDOMNode().querySelector(".decoration-2")).toBe(item2) - expect(component.getTopmostDOMNode().querySelector(".decoration-3")).toBe(item3) - expect(component.getTopmostDOMNode().querySelector(".decoration-4")).toBeNull() - expect(component.getTopmostDOMNode().querySelector(".decoration-5")).toBeNull() - expect(component.getTopmostDOMNode().querySelector(".decoration-6")).toBeNull() - expect(item2.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 3) - expect(item3.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 5 + 40) - - atom.styles.addStyleSheet( - 'atom-text-editor .decoration-2 { height: 60px; }', - {context: 'atom-text-editor'} - ) - - runAnimationFrames() // causes the DOM to update and to retrieve new styles - runAnimationFrames() // applies the changes - expect(component.getDomNode().querySelectorAll(".line").length).toBe(7) - expect(verticalScrollbarNode.scrollHeight).toBe(editor.getScreenLineCount() * editor.getLineHeightInPixels() + 60 + 100 + 120 + 42 + 22) - expect(component.tileNodesForLines()[0].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + "px") - expect(component.tileNodesForLines()[0].style.webkitTransform).toBe("translate3d(0px, 0px, 0px)") - expect(component.tileNodesForLines()[1].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 100 + 60 + "px") - expect(component.tileNodesForLines()[1].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight}px, 0px)`) - expect(component.tileNodesForLines()[2].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 120 + 42 + "px") - expect(component.tileNodesForLines()[2].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight + component.tileNodesForLines()[1].offsetHeight}px, 0px)`) - expect(component.getTopmostDOMNode().querySelector(".decoration-1")).toBeNull() - expect(component.getTopmostDOMNode().querySelector(".decoration-2")).toBe(item2) - expect(component.getTopmostDOMNode().querySelector(".decoration-3")).toBe(item3) - expect(component.getTopmostDOMNode().querySelector(".decoration-4")).toBeNull() - expect(component.getTopmostDOMNode().querySelector(".decoration-5")).toBeNull() - expect(component.getTopmostDOMNode().querySelector(".decoration-6")).toBeNull() - expect(item2.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 3) - expect(item3.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 5 + 60) - - item2.style.height = "20px" - wrapperNode.invalidateBlockDecorationDimensions(blockDecoration2) - runAnimationFrames() // causes the DOM to update and to retrieve new styles - runAnimationFrames() // applies the changes - expect(component.getDomNode().querySelectorAll(".line").length).toBe(9) - expect(verticalScrollbarNode.scrollHeight).toBe(editor.getScreenLineCount() * editor.getLineHeightInPixels() + 20 + 100 + 120 + 42 + 22) - expect(component.tileNodesForLines()[0].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + "px") - expect(component.tileNodesForLines()[0].style.webkitTransform).toBe("translate3d(0px, 0px, 0px)") - expect(component.tileNodesForLines()[1].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 100 + 20 + "px") - expect(component.tileNodesForLines()[1].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight}px, 0px)`) - expect(component.tileNodesForLines()[2].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 120 + 42 + "px") - expect(component.tileNodesForLines()[2].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight + component.tileNodesForLines()[1].offsetHeight}px, 0px)`) - expect(component.getTopmostDOMNode().querySelector(".decoration-1")).toBeNull() - expect(component.getTopmostDOMNode().querySelector(".decoration-2")).toBe(item2) - expect(component.getTopmostDOMNode().querySelector(".decoration-3")).toBe(item3) - expect(component.getTopmostDOMNode().querySelector(".decoration-4")).toBe(item4) - expect(component.getTopmostDOMNode().querySelector(".decoration-5")).toBe(item5) - expect(component.getTopmostDOMNode().querySelector(".decoration-6")).toBeNull() - expect(item2.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 3) - expect(item3.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 5 + 20) - expect(item4.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 8 + 20 + 100) - expect(item5.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 8 + 20 + 100 + 120 + lineHeightInPixels) - - item6.style.height = "33px" - wrapperNode.invalidateBlockDecorationDimensions(blockDecoration6) - runAnimationFrames() // causes the DOM to update and to retrieve new styles - runAnimationFrames() // applies the changes - expect(component.getDomNode().querySelectorAll(".line").length).toBe(9) - expect(verticalScrollbarNode.scrollHeight).toBe(editor.getScreenLineCount() * editor.getLineHeightInPixels() + 20 + 100 + 120 + 42 + 33) - expect(component.tileNodesForLines()[0].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + "px") - expect(component.tileNodesForLines()[0].style.webkitTransform).toBe("translate3d(0px, 0px, 0px)") - expect(component.tileNodesForLines()[1].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 100 + 20 + "px") - expect(component.tileNodesForLines()[1].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight}px, 0px)`) - expect(component.tileNodesForLines()[2].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 120 + 42 + "px") - expect(component.tileNodesForLines()[2].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight + component.tileNodesForLines()[1].offsetHeight}px, 0px)`) - expect(component.getTopmostDOMNode().querySelector(".decoration-1")).toBeNull() - expect(component.getTopmostDOMNode().querySelector(".decoration-2")).toBe(item2) - expect(component.getTopmostDOMNode().querySelector(".decoration-3")).toBe(item3) - expect(component.getTopmostDOMNode().querySelector(".decoration-4")).toBe(item4) - expect(component.getTopmostDOMNode().querySelector(".decoration-5")).toBe(item5) - expect(component.getTopmostDOMNode().querySelector(".decoration-6")).toBeNull() - expect(item2.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 3) - expect(item3.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 5 + 20) - expect(item4.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 8 + 20 + 100) - expect(item5.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 8 + 20 + 100 + 120 + lineHeightInPixels) - }) - - it("correctly sets screen rows on block decoration and ruler nodes, both initially and when decorations move", function () { - let [item, blockDecoration] = createBlockDecorationBeforeScreenRow(0, {className: "decoration-1"}) - atom.styles.addStyleSheet( - 'atom-text-editor .decoration-1 { width: 30px; height: 80px; }', - {context: 'atom-text-editor'} - ) - - runAnimationFrames() - const line0 = component.lineNodeForScreenRow(0) - expect(item.previousSibling.dataset.screenRow).toBe("0") - expect(item.dataset.screenRow).toBe("0") - expect(item.nextSibling.dataset.screenRow).toBe("0") - expect(line0.previousSibling).toBe(item.nextSibling) - - editor.setCursorBufferPosition([0, 0]) - editor.insertNewline() - runAnimationFrames() - const line1 = component.lineNodeForScreenRow(1) - expect(item.previousSibling.dataset.screenRow).toBe("1") - expect(item.dataset.screenRow).toBe("1") - expect(item.nextSibling.dataset.screenRow).toBe("1") - expect(line1.previousSibling).toBe(item.nextSibling) - - editor.setCursorBufferPosition([0, 0]) - editor.insertNewline() - runAnimationFrames() - const line2 = component.lineNodeForScreenRow(2) - expect(item.previousSibling.dataset.screenRow).toBe("2") - expect(item.dataset.screenRow).toBe("2") - expect(item.nextSibling.dataset.screenRow).toBe("2") - expect(line2.previousSibling).toBe(item.nextSibling) - - blockDecoration.getMarker().setHeadBufferPosition([4, 0]) - runAnimationFrames() - const line4 = component.lineNodeForScreenRow(4) - expect(item.previousSibling.dataset.screenRow).toBe("4") - expect(item.dataset.screenRow).toBe("4") - expect(item.nextSibling.dataset.screenRow).toBe("4") - expect(line4.previousSibling).toBe(item.nextSibling) - }) - - it('measures block decorations taking into account both top and bottom margins of the element and its children', function () { - let [item, blockDecoration] = createBlockDecorationBeforeScreenRow(0, {className: "decoration-1"}) - let child = document.createElement("div") - child.style.height = "7px" - child.style.width = "30px" - child.style.marginBottom = "20px" - item.appendChild(child) - atom.styles.addStyleSheet( - 'atom-text-editor .decoration-1 { width: 30px; margin-top: 10px; }', - {context: 'atom-text-editor'} - ) - - runAnimationFrames() // causes the DOM to update and to retrieve new styles - runAnimationFrames() // applies the changes - - expect(component.tileNodesForLines()[0].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 10 + 7 + 20 + "px") - expect(component.tileNodesForLines()[0].style.webkitTransform).toBe("translate3d(0px, 0px, 0px)") - expect(component.tileNodesForLines()[1].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + "px") - expect(component.tileNodesForLines()[1].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight}px, 0px)`) - expect(component.tileNodesForLines()[2].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + "px") - expect(component.tileNodesForLines()[2].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight + component.tileNodesForLines()[1].offsetHeight}px, 0px)`) - }) - - it('allows the same block decoration item to be moved from one tile to another in the same animation frame', function () { - let [item, blockDecoration] = createBlockDecorationBeforeScreenRow(5, {className: "decoration-1"}) - runAnimationFrames() - expect(component.tileNodesForLines()[0].querySelector('.decoration-1')).toBeNull() - expect(component.tileNodesForLines()[1].querySelector('.decoration-1')).toBe(item) - - blockDecoration.getMarker().setHeadBufferPosition([0, 0]) - runAnimationFrames() - expect(component.tileNodesForLines()[0].querySelector('.decoration-1')).toBe(item) - expect(component.tileNodesForLines()[1].querySelector('.decoration-1')).toBeNull() - }) - }) - - describe('highlight decoration rendering', function () { - let decoration, marker, scrollViewClientLeft - - beforeEach(async function () { - scrollViewClientLeft = componentNode.querySelector('.scroll-view').getBoundingClientRect().left - marker = editor.addMarkerLayer({ - maintainHistory: true - }).markBufferRange([[2, 13], [3, 15]], { - invalidate: 'inside' - }) - decoration = editor.decorateMarker(marker, { - type: 'highlight', - 'class': 'test-highlight' - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - }) - - it('does not render highlights for off-screen lines until they come on-screen', async function () { - wrapperNode.style.height = 2.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - marker = editor.markBufferRange([[9, 2], [9, 4]], { - invalidate: 'inside' - }) - editor.decorateMarker(marker, { - type: 'highlight', - 'class': 'some-highlight' - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(component.presenter.endRow).toBeLessThan(9) - let regions = componentNode.querySelectorAll('.some-highlight .region') - expect(regions.length).toBe(0) - verticalScrollbarNode.scrollTop = 6 * lineHeightInPixels - verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - runAnimationFrames(true) - - expect(component.presenter.endRow).toBeGreaterThan(8) - regions = componentNode.querySelectorAll('.some-highlight .region') - expect(regions.length).toBe(1) - let regionRect = regions[0].style - expect(regionRect.top).toBe(0 + 'px') - expect(regionRect.height).toBe(1 * lineHeightInPixels + 'px') - expect(regionRect.left).toBe(Math.round(2 * charWidth) + 'px') - expect(regionRect.width).toBe(Math.round(2 * charWidth) + 'px') - }) - - it('renders highlights decoration\'s marker is added', function () { - let regions = componentNode.querySelectorAll('.test-highlight .region') - expect(regions.length).toBe(2) - }) - - it('removes highlights when a decoration is removed', async function () { - decoration.destroy() - await decorationsUpdatedPromise(editor) - runAnimationFrames() - let regions = componentNode.querySelectorAll('.test-highlight .region') - expect(regions.length).toBe(0) - }) - - it('does not render a highlight that is within a fold', async function () { - editor.foldBufferRow(1) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - expect(componentNode.querySelectorAll('.test-highlight').length).toBe(0) - }) - - it('removes highlights when a decoration\'s marker is destroyed', async function () { - marker.destroy() - await decorationsUpdatedPromise(editor) - runAnimationFrames() - let regions = componentNode.querySelectorAll('.test-highlight .region') - expect(regions.length).toBe(0) - }) - - it('only renders highlights when a decoration\'s marker is valid', async function () { - editor.getBuffer().insert([3, 2], 'n') - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(marker.isValid()).toBe(false) - let regions = componentNode.querySelectorAll('.test-highlight .region') - expect(regions.length).toBe(0) - editor.getBuffer().undo() - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(marker.isValid()).toBe(true) - regions = componentNode.querySelectorAll('.test-highlight .region') - expect(regions.length).toBe(2) - }) - - it('allows multiple space-delimited decoration classes', async function () { - decoration.setProperties({ - type: 'highlight', - 'class': 'foo bar' - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - expect(componentNode.querySelectorAll('.foo.bar').length).toBe(2) - decoration.setProperties({ - type: 'highlight', - 'class': 'bar baz' - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - expect(componentNode.querySelectorAll('.bar.baz').length).toBe(2) - }) - - it('renders classes on the regions directly if "deprecatedRegionClass" option is defined', async function () { - decoration = editor.decorateMarker(marker, { - type: 'highlight', - 'class': 'test-highlight', - deprecatedRegionClass: 'test-highlight-region' - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - let regions = componentNode.querySelectorAll('.test-highlight .region.test-highlight-region') - expect(regions.length).toBe(2) - }) - - describe('when flashing a decoration via Decoration::flash()', function () { - let highlightNode - - beforeEach(function () { - highlightNode = componentNode.querySelectorAll('.test-highlight')[1] - }) - - it('adds and removes the flash class specified in ::flash', async function () { - expect(highlightNode.classList.contains('flash-class')).toBe(false) - decoration.flash('flash-class', 10) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(highlightNode.classList.contains('flash-class')).toBe(true) - advanceClock(10) - expect(highlightNode.classList.contains('flash-class')).toBe(false) - }) - - describe('when ::flash is called again before the first has finished', function () { - it('removes the class from the decoration highlight before adding it for the second ::flash call', async function () { - decoration.flash('flash-class', 500) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - expect(highlightNode.classList.contains('flash-class')).toBe(true) - - decoration.flash('flash-class', 500) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(highlightNode.classList.contains('flash-class')).toBe(false) - runAnimationFrames() - expect(highlightNode.classList.contains('flash-class')).toBe(true) - advanceClock(500) - expect(highlightNode.classList.contains('flash-class')).toBe(false) - }) - }) - }) - - describe('when a decoration\'s marker moves', function () { - it('moves rendered highlights when the buffer is changed', async function () { - let regionStyle = componentNode.querySelector('.test-highlight .region').style - let originalTop = parseInt(regionStyle.top) - expect(originalTop).toBe(2 * lineHeightInPixels) - - editor.getBuffer().insert([0, 0], '\n') - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - regionStyle = componentNode.querySelector('.test-highlight .region').style - let newTop = parseInt(regionStyle.top) - expect(newTop).toBe(0) - }) - - it('moves rendered highlights when the marker is manually moved', async function () { - let regionStyle = componentNode.querySelector('.test-highlight .region').style - expect(parseInt(regionStyle.top)).toBe(2 * lineHeightInPixels) - - marker.setBufferRange([[5, 8], [5, 13]]) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - regionStyle = componentNode.querySelector('.test-highlight .region').style - expect(parseInt(regionStyle.top)).toBe(2 * lineHeightInPixels) - }) - }) - - describe('when a decoration is updated via Decoration::update', function () { - it('renders the decoration\'s new params', async function () { - expect(componentNode.querySelector('.test-highlight')).toBeTruthy() - decoration.setProperties({ - type: 'highlight', - 'class': 'new-test-highlight' - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - expect(componentNode.querySelector('.test-highlight')).toBeFalsy() - expect(componentNode.querySelector('.new-test-highlight')).toBeTruthy() - }) - }) - }) - - describe('overlay decoration rendering', function () { - let gutterWidth, item - - beforeEach(function () { - item = document.createElement('div') - item.classList.add('overlay-test') - item.style.background = 'red' - gutterWidth = componentNode.querySelector('.gutter').offsetWidth - }) - - describe('when the marker is empty', function () { - it('renders an overlay decoration when added and removes the overlay when the decoration is destroyed', async function () { - let marker = editor.markBufferRange([[2, 13], [2, 13]], { - invalidate: 'never' - }) - let decoration = editor.decorateMarker(marker, { - type: 'overlay', - item: item - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - let overlay = component.getTopmostDOMNode().querySelector('atom-overlay .overlay-test') - expect(overlay).toBe(item) - - decoration.destroy() - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - overlay = component.getTopmostDOMNode().querySelector('atom-overlay .overlay-test') - expect(overlay).toBe(null) - }) - - it('renders the overlay element with the CSS class specified by the decoration', async function () { - let marker = editor.markBufferRange([[2, 13], [2, 13]], { - invalidate: 'never' - }) - let decoration = editor.decorateMarker(marker, { - type: 'overlay', - 'class': 'my-overlay', - item: item - }) - - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - let overlay = component.getTopmostDOMNode().querySelector('atom-overlay.my-overlay') - expect(overlay).not.toBe(null) - let child = overlay.querySelector('.overlay-test') - expect(child).toBe(item) - }) - }) - - describe('when the marker is not empty', function () { - it('renders at the head of the marker by default', async function () { - let marker = editor.markBufferRange([[2, 5], [2, 10]], { - invalidate: 'never' - }) - let decoration = editor.decorateMarker(marker, { - type: 'overlay', - item: item - }) - - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - let position = wrapperNode.pixelPositionForBufferPosition([2, 10]) - let overlay = component.getTopmostDOMNode().querySelector('atom-overlay') - expect(overlay.style.left).toBe(Math.round(position.left + gutterWidth) + 'px') - expect(overlay.style.top).toBe(position.top + editor.getLineHeightInPixels() + 'px') - }) - }) - - describe('positioning the overlay when near the edge of the editor', function () { - let itemHeight, itemWidth, windowHeight, windowWidth - - beforeEach(async function () { - atom.storeWindowDimensions() - itemWidth = Math.round(4 * editor.getDefaultCharWidth()) - itemHeight = 4 * editor.getLineHeightInPixels() - windowWidth = Math.round(gutterWidth + 30 * editor.getDefaultCharWidth()) - windowHeight = 10 * editor.getLineHeightInPixels() - item.style.width = itemWidth + 'px' - item.style.height = itemHeight + 'px' - wrapperNode.style.width = windowWidth + 'px' - wrapperNode.style.height = windowHeight + 'px' - editor.update({autoHeight: false}) - await atom.setWindowDimensions({ - width: windowWidth, - height: windowHeight - }) - - component.measureDimensions() - component.measureWindowSize() - runAnimationFrames() - }) - - afterEach(function () { - atom.restoreWindowDimensions() - }) - - it('slides horizontally left when near the right edge on #win32 and #darwin', async function () { - let marker = editor.markBufferRange([[0, 26], [0, 26]], { - invalidate: 'never' - }) - let decoration = editor.decorateMarker(marker, { - type: 'overlay', - item: item - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - let position = wrapperNode.pixelPositionForBufferPosition([0, 26]) - let overlay = component.getTopmostDOMNode().querySelector('atom-overlay') - if (process.platform == 'darwin') { // Result is 359px on win32, expects 375px - expect(overlay.style.left).toBe(Math.round(position.left + gutterWidth) + 'px') - } - expect(overlay.style.top).toBe(position.top + editor.getLineHeightInPixels() + 'px') - - editor.insertText('a') - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(overlay.style.left).toBe(window.innerWidth - itemWidth + 'px') - expect(overlay.style.top).toBe(position.top + editor.getLineHeightInPixels() + 'px') - - editor.insertText('b') - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(overlay.style.left).toBe(window.innerWidth - itemWidth + 'px') - expect(overlay.style.top).toBe(position.top + editor.getLineHeightInPixels() + 'px') - - // window size change - const innerWidthBefore = window.innerWidth - await atom.setWindowDimensions({ - width: Math.round(gutterWidth + 20 * editor.getDefaultCharWidth()), - height: windowHeight, - }) - // wait for window to resize :( - await conditionPromise(() => { - return window.innerWidth !== innerWidthBefore - }) - - runAnimationFrames() - - expect(overlay.style.left).toBe(window.innerWidth - itemWidth + 'px') - expect(overlay.style.top).toBe(position.top + editor.getLineHeightInPixels() + 'px') - }) - }) - }) - - describe('hidden input field', function () { - it('renders the hidden input field at the position of the last cursor if the cursor is on screen and the editor is focused', async function () { - editor.setVerticalScrollMargin(0) - editor.setHorizontalScrollMargin(0) - let inputNode = componentNode.querySelector('.hidden-input') - wrapperNode.style.height = 5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 10 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - expect(editor.getCursorScreenPosition()).toEqual([0, 0]) - - wrapperNode.setScrollTop(3 * lineHeightInPixels) - wrapperNode.setScrollLeft(3 * charWidth) - runAnimationFrames() - - expect(inputNode.offsetTop).toBe(0) - expect(inputNode.offsetLeft).toBe(0) - - editor.setCursorBufferPosition([5, 4], { - autoscroll: false - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(inputNode.offsetTop).toBe(0) - expect(inputNode.offsetLeft).toBe(0) - - wrapperNode.focus() - runAnimationFrames() - - expect(inputNode.offsetTop).toBe((5 * lineHeightInPixels) - wrapperNode.getScrollTop()) - expect(inputNode.offsetLeft).toBeCloseTo((4 * charWidth) - wrapperNode.getScrollLeft(), 0) - - inputNode.blur() - runAnimationFrames() - - expect(inputNode.offsetTop).toBe(0) - expect(inputNode.offsetLeft).toBe(0) - - editor.setCursorBufferPosition([1, 2], { - autoscroll: false - }) - runAnimationFrames() - - expect(inputNode.offsetTop).toBe(0) - expect(inputNode.offsetLeft).toBe(0) - - inputNode.focus() - runAnimationFrames() - - expect(inputNode.offsetTop).toBe(0) - expect(inputNode.offsetLeft).toBe(0) - }) - }) - - describe('mouse interactions on the lines', function () { - let linesNode - - beforeEach(function () { - linesNode = componentNode.querySelector('.lines') - }) - - describe('when the mouse is single-clicked above the first line', function () { - it('moves the cursor to the start of file buffer position', function () { - let height - editor.setText('foo') - editor.setCursorBufferPosition([0, 3]) - height = 4.5 * lineHeightInPixels - wrapperNode.style.height = height + 'px' - wrapperNode.style.width = 10 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - let coordinates = clientCoordinatesForScreenPosition([0, 2]) - coordinates.clientY = -1 - linesNode.dispatchEvent(buildMouseEvent('mousedown', coordinates)) - - runAnimationFrames() - expect(editor.getCursorScreenPosition()).toEqual([0, 0]) - }) - }) - - describe('when the mouse is single-clicked below the last line', function () { - it('moves the cursor to the end of file buffer position', function () { - editor.setText('foo') - editor.setCursorBufferPosition([0, 0]) - let height = 4.5 * lineHeightInPixels - wrapperNode.style.height = height + 'px' - wrapperNode.style.width = 10 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - let coordinates = clientCoordinatesForScreenPosition([0, 2]) - coordinates.clientY = height * 2 - - linesNode.dispatchEvent(buildMouseEvent('mousedown', coordinates)) - runAnimationFrames() - - expect(editor.getCursorScreenPosition()).toEqual([0, 3]) - }) - }) - - describe('when a non-folded line is single-clicked', function () { - describe('when no modifier keys are held down', function () { - it('moves the cursor to the nearest screen position', function () { - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 10 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - wrapperNode.setScrollTop(3.5 * lineHeightInPixels) - wrapperNode.setScrollLeft(2 * charWidth) - runAnimationFrames() - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([4, 8]))) - runAnimationFrames() - expect(editor.getCursorScreenPosition()).toEqual([4, 8]) - }) - }) - - describe('when the shift key is held down', function () { - it('selects to the nearest screen position', function () { - editor.setCursorScreenPosition([3, 4]) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 6]), { - shiftKey: true - })) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[3, 4], [5, 6]]) - }) - }) - - describe('when the command key is held down', function () { - describe('the current cursor position and screen position do not match', function () { - it('adds a cursor at the nearest screen position', function () { - editor.setCursorScreenPosition([3, 4]) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 6]), { - metaKey: true - })) - runAnimationFrames() - expect(editor.getSelectedScreenRanges()).toEqual([[[3, 4], [3, 4]], [[5, 6], [5, 6]]]) - }) - }) - - describe('when there are multiple cursors, and one of the cursor\'s screen position is the same as the mouse click screen position', function () { - it('removes a cursor at the mouse screen position', function () { - editor.setCursorScreenPosition([3, 4]) - editor.addCursorAtScreenPosition([5, 2]) - editor.addCursorAtScreenPosition([7, 5]) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([3, 4]), { - metaKey: true - })) - runAnimationFrames() - expect(editor.getSelectedScreenRanges()).toEqual([[[5, 2], [5, 2]], [[7, 5], [7, 5]]]) - }) - }) - - describe('when there is a single cursor and the click occurs at the cursor\'s screen position', function () { - it('neither adds a new cursor nor removes the current cursor', function () { - editor.setCursorScreenPosition([3, 4]) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([3, 4]), { - metaKey: true - })) - runAnimationFrames() - expect(editor.getSelectedScreenRanges()).toEqual([[[3, 4], [3, 4]]]) - }) - }) - }) - }) - - describe('when a non-folded line is double-clicked', function () { - describe('when no modifier keys are held down', function () { - it('selects the word containing the nearest screen position', function () { - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 2 - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - expect(editor.getSelectedScreenRange()).toEqual([[5, 6], [5, 13]]) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([6, 6]), { - detail: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - expect(editor.getSelectedScreenRange()).toEqual([[6, 6], [6, 6]]) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([8, 8]), { - detail: 1, - shiftKey: true - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - expect(editor.getSelectedScreenRange()).toEqual([[6, 6], [8, 8]]) - }) - }) - - describe('when the command key is held down', function () { - it('selects the word containing the newly-added cursor', function () { - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 1, - metaKey: true - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 2, - metaKey: true - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - expect(editor.getSelectedScreenRanges()).toEqual([[[0, 0], [0, 0]], [[5, 6], [5, 13]]]) - }) - }) - }) - - describe('when a non-folded line is triple-clicked', function () { - describe('when no modifier keys are held down', function () { - it('selects the line containing the nearest screen position', function () { - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 2 - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 3 - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [6, 0]]) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([6, 6]), { - detail: 1, - shiftKey: true - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [7, 0]]) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([7, 5]), { - detail: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([8, 8]), { - detail: 1, - shiftKey: true - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - expect(editor.getSelectedScreenRange()).toEqual([[7, 5], [8, 8]]) - }) - }) - - describe('when the command key is held down', function () { - it('selects the line containing the newly-added cursor', function () { - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 1, - metaKey: true - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 2, - metaKey: true - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 3, - metaKey: true - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - expect(editor.getSelectedScreenRanges()).toEqual([[[0, 0], [0, 0]], [[5, 0], [6, 0]]]) - }) - }) - }) - - describe('when the mouse is clicked and dragged', function () { - it('selects to the nearest screen position until the mouse button is released', function () { - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), { - which: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), { - which: 1 - })) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [6, 8]]) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([10, 0]), { - which: 1 - })) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [10, 0]]) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([12, 0]), { - which: 1 - })) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [10, 0]]) - }) - - it('autoscrolls when the cursor approaches the boundaries of the editor', function () { - wrapperNode.style.height = '100px' - wrapperNode.style.width = '100px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - expect(wrapperNode.getScrollLeft()).toBe(0) - - linesNode.dispatchEvent(buildMouseEvent('mousedown', { - clientX: 0, - clientY: 0 - }, { - which: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mousemove', { - clientX: 100, - clientY: 50 - }, { - which: 1 - })) - - for (let i = 0; i <= 5; ++i) { - runAnimationFrames() - } - - expect(wrapperNode.getScrollTop()).toBe(0) - expect(wrapperNode.getScrollLeft()).toBeGreaterThan(0) - linesNode.dispatchEvent(buildMouseEvent('mousemove', { - clientX: 100, - clientY: 100 - }, { - which: 1 - })) - - for (let i = 0; i <= 5; ++i) { - runAnimationFrames() - } - - expect(wrapperNode.getScrollTop()).toBeGreaterThan(0) - let previousScrollTop = wrapperNode.getScrollTop() - let previousScrollLeft = wrapperNode.getScrollLeft() - - linesNode.dispatchEvent(buildMouseEvent('mousemove', { - clientX: 10, - clientY: 50 - }, { - which: 1 - })) - - for (let i = 0; i <= 5; ++i) { - runAnimationFrames() - } - - expect(wrapperNode.getScrollTop()).toBe(previousScrollTop) - expect(wrapperNode.getScrollLeft()).toBeLessThan(previousScrollLeft) - linesNode.dispatchEvent(buildMouseEvent('mousemove', { - clientX: 10, - clientY: 10 - }, { - which: 1 - })) - - for (let i = 0; i <= 5; ++i) { - runAnimationFrames() - } - - expect(wrapperNode.getScrollTop()).toBeLessThan(previousScrollTop) - }) - - it('stops selecting if the mouse is dragged into the dev tools', function () { - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), { - which: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), { - which: 1 - })) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [6, 8]]) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([10, 0]), { - which: 0 - })) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [6, 8]]) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([8, 0]), { - which: 1 - })) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [6, 8]]) - }) - - it('stops selecting before the buffer is modified during the drag', function () { - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), { - which: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), { - which: 1 - })) - runAnimationFrames() - - expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [6, 8]]) - - editor.insertText('x') - runAnimationFrames() - - expect(editor.getSelectedScreenRange()).toEqual([[2, 5], [2, 5]]) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([8, 0]), { - which: 1 - })) - expect(editor.getSelectedScreenRange()).toEqual([[2, 5], [2, 5]]) - - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), { - which: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([5, 4]), { - which: 1 - })) - runAnimationFrames() - - expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [5, 4]]) - - editor.delete() - runAnimationFrames() - - expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [2, 4]]) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([8, 0]), { - which: 1 - })) - expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [2, 4]]) - }) - - describe('when the command key is held down', function () { - it('adds a new selection and selects to the nearest screen position, then merges intersecting selections when the mouse button is released', function () { - editor.setSelectedScreenRange([[4, 4], [4, 9]]) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), { - which: 1, - metaKey: true - })) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), { - which: 1 - })) - runAnimationFrames() - - expect(editor.getSelectedScreenRanges()).toEqual([[[4, 4], [4, 9]], [[2, 4], [6, 8]]]) - - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([4, 6]), { - which: 1 - })) - runAnimationFrames() - - expect(editor.getSelectedScreenRanges()).toEqual([[[4, 4], [4, 9]], [[2, 4], [4, 6]]]) - linesNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenPosition([4, 6]), { - which: 1 - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[2, 4], [4, 9]]]) - }) - }) - - describe('when the editor is destroyed while dragging', function () { - it('cleans up the handlers for window.mouseup and window.mousemove', function () { - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), { - which: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), { - which: 1 - })) - runAnimationFrames() - - spyOn(window, 'removeEventListener').andCallThrough() - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 10]), { - which: 1 - })) - - editor.destroy() - runAnimationFrames() - - for (let call of window.removeEventListener.calls) { - call.args.pop() - } - expect(window.removeEventListener).toHaveBeenCalledWith('mouseup') - expect(window.removeEventListener).toHaveBeenCalledWith('mousemove') - }) - }) - }) - - describe('when the mouse is double-clicked and dragged', function () { - it('expands the selection over the nearest word as the cursor moves', function () { - jasmine.attachToDOM(wrapperNode) - wrapperNode.style.height = 6 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 2 - })) - expect(editor.getSelectedScreenRange()).toEqual([[5, 6], [5, 13]]) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([11, 11]), { - which: 1 - })) - runAnimationFrames() - - expect(editor.getSelectedScreenRange()).toEqual([[5, 6], [12, 2]]) - let maximalScrollTop = wrapperNode.getScrollTop() - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([9, 3]), { - which: 1 - })) - runAnimationFrames() - - expect(editor.getSelectedScreenRange()).toEqual([[5, 6], [9, 4]]) - expect(wrapperNode.getScrollTop()).toBe(maximalScrollTop) - linesNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenPosition([9, 3]), { - which: 1 - })) - }) - }) - - describe('when the mouse is triple-clicked and dragged', function () { - it('expands the selection over the nearest line as the cursor moves', function () { - jasmine.attachToDOM(wrapperNode) - wrapperNode.style.height = 6 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 2 - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 3 - })) - expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [6, 0]]) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([11, 11]), { - which: 1 - })) - runAnimationFrames() - - expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [12, 2]]) - let maximalScrollTop = wrapperNode.getScrollTop() - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([8, 4]), { - which: 1 - })) - runAnimationFrames() - - expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [8, 0]]) - expect(wrapperNode.getScrollTop()).toBe(maximalScrollTop) - linesNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenPosition([9, 3]), { - which: 1 - })) - }) - }) - - describe('when a fold marker is clicked', function () { - function clickElementAtPosition (marker, position) { - linesNode.dispatchEvent( - buildMouseEvent('mousedown', clientCoordinatesForScreenPosition(position), {target: marker}) - ) - } - - it('unfolds only the selected fold when other folds are on the same line', function () { - editor.foldBufferRange([[4, 6], [4, 10]]) - editor.foldBufferRange([[4, 15], [4, 20]]) - runAnimationFrames() - - let foldMarkers = component.lineNodeForScreenRow(4).querySelectorAll('.fold-marker') - expect(foldMarkers.length).toBe(2) - expect(editor.isFoldedAtBufferRow(4)).toBe(true) - - clickElementAtPosition(foldMarkers[0], [4, 6]) - runAnimationFrames() - foldMarkers = component.lineNodeForScreenRow(4).querySelectorAll('.fold-marker') - expect(foldMarkers.length).toBe(1) - expect(editor.isFoldedAtBufferRow(4)).toBe(true) - - clickElementAtPosition(foldMarkers[0], [4, 15]) - runAnimationFrames() - foldMarkers = component.lineNodeForScreenRow(4).querySelectorAll('.fold-marker') - expect(foldMarkers.length).toBe(0) - expect(editor.isFoldedAtBufferRow(4)).toBe(false) - }) - - it('unfolds only the selected fold when other folds are inside it', function () { - editor.foldBufferRange([[4, 10], [4, 15]]) - editor.foldBufferRange([[4, 4], [4, 5]]) - editor.foldBufferRange([[4, 4], [4, 20]]) - runAnimationFrames() - let foldMarkers = component.lineNodeForScreenRow(4).querySelectorAll('.fold-marker') - expect(foldMarkers.length).toBe(1) - expect(editor.isFoldedAtBufferRow(4)).toBe(true) - - clickElementAtPosition(foldMarkers[0], [4, 4]) - runAnimationFrames() - foldMarkers = component.lineNodeForScreenRow(4).querySelectorAll('.fold-marker') - expect(foldMarkers.length).toBe(1) - expect(editor.isFoldedAtBufferRow(4)).toBe(true) - - clickElementAtPosition(foldMarkers[0], [4, 4]) - runAnimationFrames() - foldMarkers = component.lineNodeForScreenRow(4).querySelectorAll('.fold-marker') - expect(foldMarkers.length).toBe(1) - expect(editor.isFoldedAtBufferRow(4)).toBe(true) - - clickElementAtPosition(foldMarkers[0], [4, 10]) - runAnimationFrames() - foldMarkers = component.lineNodeForScreenRow(4).querySelectorAll('.fold-marker') - expect(foldMarkers.length).toBe(0) - expect(editor.isFoldedAtBufferRow(4)).toBe(false) - }) - }) - - describe('when the horizontal scrollbar is interacted with', function () { - it('clicking on the scrollbar does not move the cursor', function () { - let target = horizontalScrollbarNode - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([4, 8]), { - target: target - })) - expect(editor.getCursorScreenPosition()).toEqual([0, 0]) - }) - }) - }) - - describe('mouse interactions on the gutter', function () { - let gutterNode - - beforeEach(function () { - gutterNode = componentNode.querySelector('.gutter') - }) - - describe('when the component is destroyed', function () { - it('stops listening for selection events', function () { - component.destroy() - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1))) - expect(editor.getSelectedScreenRange()).toEqual([[0, 0], [0, 0]]) - }) - }) - - describe('when the gutter is clicked', function () { - it('selects the clicked row', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(4))) - expect(editor.getSelectedScreenRange()).toEqual([[4, 0], [5, 0]]) - }) - }) - - describe('when the gutter is meta-clicked', function () { - it('creates a new selection for the clicked row', function () { - editor.setSelectedScreenRange([[3, 0], [3, 2]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(4), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[3, 0], [3, 2]], [[4, 0], [5, 0]]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[3, 0], [3, 2]], [[4, 0], [5, 0]], [[6, 0], [7, 0]]]) - }) - }) - - describe('when the gutter is shift-clicked', function () { - beforeEach(function () { - editor.setSelectedScreenRange([[3, 4], [4, 5]]) - }) - - describe('when the clicked row is before the current selection\'s tail', function () { - it('selects to the beginning of the clicked row', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), { - shiftKey: true - })) - expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [3, 4]]) - }) - }) - - describe('when the clicked row is after the current selection\'s tail', function () { - it('selects to the beginning of the row following the clicked row', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6), { - shiftKey: true - })) - expect(editor.getSelectedScreenRange()).toEqual([[3, 4], [7, 0]]) - }) - }) - }) - - describe('when the gutter is clicked and dragged', function () { - describe('when dragging downward', function () { - it('selects the rows between the start and end of the drag', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2))) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6))) - runAnimationFrames() - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(6))) - expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [7, 0]]) - }) - }) - - describe('when dragging upward', function () { - it('selects the rows between the start and end of the drag', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6))) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(2))) - runAnimationFrames() - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(2))) - expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [7, 0]]) - }) - }) - - it('orients the selection appropriately when the mouse moves above or below the initially-clicked row', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(4))) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(2))) - runAnimationFrames() - expect(editor.getLastSelection().isReversed()).toBe(true) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6))) - runAnimationFrames() - expect(editor.getLastSelection().isReversed()).toBe(false) - }) - - it('autoscrolls when the cursor approaches the top or bottom of the editor', function () { - wrapperNode.style.height = 6 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2))) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(8))) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBeGreaterThan(0) - let maxScrollTop = wrapperNode.getScrollTop() - - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(10))) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(maxScrollTop) - - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(7))) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBeLessThan(maxScrollTop) - }) - - it('stops selecting if a textInput event occurs during the drag', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2))) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6))) - runAnimationFrames() - - expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [7, 0]]) - - let inputEvent = new Event('textInput') - inputEvent.data = 'x' - Object.defineProperty(inputEvent, 'target', { - get: function () { - return componentNode.querySelector('.hidden-input') - } - }) - componentNode.dispatchEvent(inputEvent) - runAnimationFrames() - - expect(editor.getSelectedScreenRange()).toEqual([[2, 1], [2, 1]]) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(12))) - expect(editor.getSelectedScreenRange()).toEqual([[2, 1], [2, 1]]) - }) - }) - - describe('when the gutter is meta-clicked and dragged', function () { - beforeEach(function () { - editor.setSelectedScreenRange([[3, 0], [3, 2]]) - }) - - describe('when dragging downward', function () { - it('selects the rows between the start and end of the drag', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(4), { - metaKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6), { - metaKey: true - })) - runAnimationFrames() - - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(6), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[3, 0], [3, 2]], [[4, 0], [7, 0]]]) - }) - - it('merges overlapping selections when the mouse button is released', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2), { - metaKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6), { - metaKey: true - })) - runAnimationFrames() - - expect(editor.getSelectedScreenRanges()).toEqual([[[3, 0], [3, 2]], [[2, 0], [7, 0]]]) - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(6), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[2, 0], [7, 0]]]) - }) - }) - - describe('when dragging upward', function () { - it('selects the rows between the start and end of the drag', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6), { - metaKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(4), { - metaKey: true - })) - runAnimationFrames() - - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(4), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[3, 0], [3, 2]], [[4, 0], [7, 0]]]) - }) - - it('merges overlapping selections', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6), { - metaKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(2), { - metaKey: true - })) - runAnimationFrames() - - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(2), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[2, 0], [7, 0]]]) - }) - }) - }) - - describe('when the gutter is shift-clicked and dragged', function () { - describe('when the shift-click is below the existing selection\'s tail', function () { - describe('when dragging downward', function () { - it('selects the rows between the existing selection\'s tail and the end of the drag', function () { - editor.setSelectedScreenRange([[3, 4], [4, 5]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(7), { - shiftKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(8))) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[3, 4], [9, 0]]) - }) - }) - - describe('when dragging upward', function () { - it('selects the rows between the end of the drag and the tail of the existing selection', function () { - editor.setSelectedScreenRange([[4, 4], [5, 5]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(7), { - shiftKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(5))) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[4, 4], [6, 0]]) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(1))) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [4, 4]]) - }) - }) - }) - - describe('when the shift-click is above the existing selection\'s tail', function () { - describe('when dragging upward', function () { - it('selects the rows between the end of the drag and the tail of the existing selection', function () { - editor.setSelectedScreenRange([[4, 4], [5, 5]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2), { - shiftKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(1))) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [4, 4]]) - }) - }) - - describe('when dragging downward', function () { - it('selects the rows between the existing selection\'s tail and the end of the drag', function () { - editor.setSelectedScreenRange([[3, 4], [4, 5]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), { - shiftKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(2))) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [3, 4]]) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(8))) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[3, 4], [9, 0]]) - }) - }) - }) - }) - - describe('when soft wrap is enabled', function () { - beforeEach(function () { - gutterNode = componentNode.querySelector('.gutter') - editor.setSoftWrapped(true) - runAnimationFrames() - componentNode.style.width = 21 * charWidth + wrapperNode.getVerticalScrollbarWidth() + 'px' - component.measureDimensions() - runAnimationFrames() - }) - - describe('when the gutter is clicked', function () { - it('selects the clicked buffer row', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1))) - expect(editor.getSelectedScreenRange()).toEqual([[0, 0], [2, 0]]) - }) - }) - - describe('when the gutter is meta-clicked', function () { - it('creates a new selection for the clicked buffer row', function () { - editor.setSelectedScreenRange([[1, 0], [1, 2]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[1, 0], [1, 2]], [[2, 0], [5, 0]]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(7), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[1, 0], [1, 2]], [[2, 0], [5, 0]], [[5, 0], [10, 0]]]) - }) - }) - - describe('when the gutter is shift-clicked', function () { - beforeEach(function () { - return editor.setSelectedScreenRange([[7, 4], [7, 6]]) - }) - - describe('when the clicked row is before the current selection\'s tail', function () { - it('selects to the beginning of the clicked buffer row', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), { - shiftKey: true - })) - expect(editor.getSelectedScreenRange()).toEqual([[0, 0], [7, 4]]) - }) - }) - - describe('when the clicked row is after the current selection\'s tail', function () { - it('selects to the beginning of the screen row following the clicked buffer row', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(11), { - shiftKey: true - })) - expect(editor.getSelectedScreenRange()).toEqual([[7, 4], [17, 0]]) - }) - }) - }) - - describe('when the gutter is clicked and dragged', function () { - describe('when dragging downward', function () { - it('selects the buffer row containing the click, then screen rows until the end of the drag', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1))) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6))) - runAnimationFrames() - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(6))) - expect(editor.getSelectedScreenRange()).toEqual([[0, 0], [6, 14]]) - }) - }) - - describe('when dragging upward', function () { - it('selects the buffer row containing the click, then screen rows until the end of the drag', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6))) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(1))) - runAnimationFrames() - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(1))) - expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [10, 0]]) - }) - }) - }) - - describe('when the gutter is meta-clicked and dragged', function () { - beforeEach(function () { - editor.setSelectedScreenRange([[7, 4], [7, 6]]) - }) - - describe('when dragging downward', function () { - it('adds a selection from the buffer row containing the click to the screen row containing the end of the drag', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), { - metaKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(3), { - metaKey: true - })) - runAnimationFrames() - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(3), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[7, 4], [7, 6]], [[0, 0], [3, 14]]]) - }) - - it('merges overlapping selections on mouseup', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), { - metaKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(7), { - metaKey: true - })) - runAnimationFrames() - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(7), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[0, 0], [7, 12]]]) - }) - }) - - describe('when dragging upward', function () { - it('adds a selection from the buffer row containing the click to the screen row containing the end of the drag', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(17), { - metaKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(11), { - metaKey: true - })) - runAnimationFrames() - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(11), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[7, 4], [7, 6]], [[11, 4], [20, 0]]]) - }) - - it('merges overlapping selections on mouseup', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(17), { - metaKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(5), { - metaKey: true - })) - runAnimationFrames() - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(5), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[5, 0], [20, 0]]]) - }) - }) - }) - - describe('when the gutter is shift-clicked and dragged', function () { - describe('when the shift-click is below the existing selection\'s tail', function () { - describe('when dragging downward', function () { - it('selects the screen rows between the existing selection\'s tail and the end of the drag', function () { - editor.setSelectedScreenRange([[1, 4], [1, 7]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(7), { - shiftKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(11))) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [11, 5]]) - }) - }) - - describe('when dragging upward', function () { - it('selects the screen rows between the end of the drag and the tail of the existing selection', function () { - editor.setSelectedScreenRange([[1, 4], [1, 7]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(11), { - shiftKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(7))) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [7, 12]]) - }) - }) - }) - - describe('when the shift-click is above the existing selection\'s tail', function () { - describe('when dragging upward', function () { - it('selects the screen rows between the end of the drag and the tail of the existing selection', function () { - editor.setSelectedScreenRange([[7, 4], [7, 6]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(3), { - shiftKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(1))) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [7, 4]]) - }) - }) - - describe('when dragging downward', function () { - it('selects the screen rows between the existing selection\'s tail and the end of the drag', function () { - editor.setSelectedScreenRange([[7, 4], [7, 6]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), { - shiftKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(3))) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[3, 2], [7, 4]]) - }) - }) - }) - }) - }) - }) - - describe('focus handling', function () { - let inputNode - beforeEach(function () { - inputNode = componentNode.querySelector('.hidden-input') - }) - - it('transfers focus to the hidden input', function () { - expect(document.activeElement).toBe(document.body) - wrapperNode.focus() - expect(document.activeElement).toBe(inputNode) - }) - - it('adds the "is-focused" class to the editor when the hidden input is focused', function () { - expect(document.activeElement).toBe(document.body) - inputNode.focus() - runAnimationFrames() - - expect(componentNode.classList.contains('is-focused')).toBe(true) - expect(wrapperNode.classList.contains('is-focused')).toBe(true) - inputNode.blur() - runAnimationFrames() - - expect(componentNode.classList.contains('is-focused')).toBe(false) - expect(wrapperNode.classList.contains('is-focused')).toBe(false) - }) - }) - - describe('selection handling', function () { - let cursor - - beforeEach(function () { - editor.setCursorScreenPosition([0, 0]) - runAnimationFrames() - }) - - it('adds the "has-selection" class to the editor when there is a selection', function () { - expect(componentNode.classList.contains('has-selection')).toBe(false) - editor.selectDown() - runAnimationFrames() - expect(componentNode.classList.contains('has-selection')).toBe(true) - editor.moveDown() - runAnimationFrames() - expect(componentNode.classList.contains('has-selection')).toBe(false) - }) - }) - - describe('scrolling', function () { - it('updates the vertical scrollbar when the scrollTop is changed in the model', function () { - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - expect(verticalScrollbarNode.scrollTop).toBe(0) - wrapperNode.setScrollTop(10) - runAnimationFrames() - expect(verticalScrollbarNode.scrollTop).toBe(10) - }) - - it('updates the horizontal scrollbar and the x transform of the lines based on the scrollLeft of the model', function () { - componentNode.style.width = 30 * charWidth + 'px' - component.measureDimensions() - runAnimationFrames() - - let top = 0 - let tilesNodes = component.tileNodesForLines() - for (let tileNode of tilesNodes) { - expect(tileNode.style['-webkit-transform']).toBe('translate3d(0px, ' + top + 'px, 0px)') - top += tileNode.offsetHeight - } - expect(horizontalScrollbarNode.scrollLeft).toBe(0) - wrapperNode.setScrollLeft(100) - - runAnimationFrames() - - top = 0 - for (let tileNode of tilesNodes) { - expect(tileNode.style['-webkit-transform']).toBe('translate3d(-100px, ' + top + 'px, 0px)') - top += tileNode.offsetHeight - } - expect(horizontalScrollbarNode.scrollLeft).toBe(100) - }) - - it('updates the scrollLeft of the model when the scrollLeft of the horizontal scrollbar changes', function () { - componentNode.style.width = 30 * charWidth + 'px' - component.measureDimensions() - runAnimationFrames() - expect(wrapperNode.getScrollLeft()).toBe(0) - horizontalScrollbarNode.scrollLeft = 100 - horizontalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - runAnimationFrames(true) - expect(wrapperNode.getScrollLeft()).toBe(100) - }) - - it('does not obscure the last line with the horizontal scrollbar', function () { - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 10 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - wrapperNode.setScrollBottom(wrapperNode.getScrollHeight()) - runAnimationFrames() - - let lastLineNode = component.lineNodeForScreenRow(editor.getLastScreenRow()) - let bottomOfLastLine = lastLineNode.getBoundingClientRect().bottom - topOfHorizontalScrollbar = horizontalScrollbarNode.getBoundingClientRect().top - expect(bottomOfLastLine).toBe(topOfHorizontalScrollbar) - wrapperNode.style.width = 100 * charWidth + 'px' - component.measureDimensions() - runAnimationFrames() - - bottomOfLastLine = lastLineNode.getBoundingClientRect().bottom - let bottomOfEditor = componentNode.getBoundingClientRect().bottom - expect(bottomOfLastLine).toBe(bottomOfEditor) - }) - - it('does not obscure the last character of the longest line with the vertical scrollbar', function () { - wrapperNode.style.height = 7 * lineHeightInPixels + 'px' - wrapperNode.style.width = 10 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - wrapperNode.setScrollLeft(Infinity) - - runAnimationFrames() - let rightOfLongestLine = component.lineNodeForScreenRow(6).querySelector('.line > span:last-child').getBoundingClientRect().right - let leftOfVerticalScrollbar = verticalScrollbarNode.getBoundingClientRect().left - expect(Math.round(rightOfLongestLine)).toBeCloseTo(leftOfVerticalScrollbar - 1, 0) - }) - - it('only displays dummy scrollbars when scrollable in that direction', function () { - expect(verticalScrollbarNode.style.display).toBe('none') - expect(horizontalScrollbarNode.style.display).toBe('none') - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = '1000px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - expect(verticalScrollbarNode.style.display).toBe('') - expect(horizontalScrollbarNode.style.display).toBe('none') - componentNode.style.width = 10 * charWidth + 'px' - component.measureDimensions() - runAnimationFrames() - - expect(verticalScrollbarNode.style.display).toBe('') - expect(horizontalScrollbarNode.style.display).toBe('') - wrapperNode.style.height = 20 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - expect(verticalScrollbarNode.style.display).toBe('none') - expect(horizontalScrollbarNode.style.display).toBe('') - }) - - it('makes the dummy scrollbar divs only as tall/wide as the actual scrollbars', function () { - wrapperNode.style.height = 4 * lineHeightInPixels + 'px' - wrapperNode.style.width = 10 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - atom.styles.addStyleSheet('::-webkit-scrollbar {\n width: 8px;\n height: 8px;\n}', { - context: 'atom-text-editor' - }) - - runAnimationFrames() - runAnimationFrames() - - let scrollbarCornerNode = componentNode.querySelector('.scrollbar-corner') - expect(verticalScrollbarNode.offsetWidth).toBe(8) - expect(horizontalScrollbarNode.offsetHeight).toBe(8) - expect(scrollbarCornerNode.offsetWidth).toBe(8) - expect(scrollbarCornerNode.offsetHeight).toBe(8) - atom.themes.removeStylesheet('test') - }) - - it('assigns the bottom/right of the scrollbars to the width of the opposite scrollbar if it is visible', function () { - let scrollbarCornerNode = componentNode.querySelector('.scrollbar-corner') - expect(verticalScrollbarNode.style.bottom).toBe('0px') - expect(horizontalScrollbarNode.style.right).toBe('0px') - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = '1000px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - expect(verticalScrollbarNode.style.bottom).toBe('0px') - expect(horizontalScrollbarNode.style.right).toBe(verticalScrollbarNode.offsetWidth + 'px') - expect(scrollbarCornerNode.style.display).toBe('none') - componentNode.style.width = 10 * charWidth + 'px' - component.measureDimensions() - runAnimationFrames() - - expect(verticalScrollbarNode.style.bottom).toBe(horizontalScrollbarNode.offsetHeight + 'px') - expect(horizontalScrollbarNode.style.right).toBe(verticalScrollbarNode.offsetWidth + 'px') - expect(scrollbarCornerNode.style.display).toBe('') - wrapperNode.style.height = 20 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - expect(verticalScrollbarNode.style.bottom).toBe(horizontalScrollbarNode.offsetHeight + 'px') - expect(horizontalScrollbarNode.style.right).toBe('0px') - expect(scrollbarCornerNode.style.display).toBe('none') - }) - - it('accounts for the width of the gutter in the scrollWidth of the horizontal scrollbar', function () { - let gutterNode = componentNode.querySelector('.gutter') - componentNode.style.width = 10 * charWidth + 'px' - component.measureDimensions() - runAnimationFrames() - - expect(horizontalScrollbarNode.scrollWidth).toBe(wrapperNode.getScrollWidth()) - expect(horizontalScrollbarNode.style.left).toBe('0px') - }) - }) - - describe('mousewheel events', function () { - beforeEach(function () { - editor.update({scrollSensitivity: 100}) - }) - - describe('updating scrollTop and scrollLeft', function () { - beforeEach(function () { - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 20 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - }) - - it('updates the scrollLeft or scrollTop on mousewheel events depending on which delta is greater (x or y)', function () { - expect(verticalScrollbarNode.scrollTop).toBe(0) - expect(horizontalScrollbarNode.scrollLeft).toBe(0) - componentNode.dispatchEvent(new WheelEvent('mousewheel', { - wheelDeltaX: -5, - wheelDeltaY: -10 - })) - runAnimationFrames() - - expect(verticalScrollbarNode.scrollTop).toBe(10) - expect(horizontalScrollbarNode.scrollLeft).toBe(0) - componentNode.dispatchEvent(new WheelEvent('mousewheel', { - wheelDeltaX: -15, - wheelDeltaY: -5 - })) - runAnimationFrames() - - expect(verticalScrollbarNode.scrollTop).toBe(10) - expect(horizontalScrollbarNode.scrollLeft).toBe(15) - }) - - it('updates the scrollLeft or scrollTop according to the scroll sensitivity', function () { - editor.update({scrollSensitivity: 50}) - componentNode.dispatchEvent(new WheelEvent('mousewheel', { - wheelDeltaX: -5, - wheelDeltaY: -10 - })) - runAnimationFrames() - - expect(horizontalScrollbarNode.scrollLeft).toBe(0) - componentNode.dispatchEvent(new WheelEvent('mousewheel', { - wheelDeltaX: -15, - wheelDeltaY: -5 - })) - runAnimationFrames() - - expect(verticalScrollbarNode.scrollTop).toBe(5) - expect(horizontalScrollbarNode.scrollLeft).toBe(7) - }) - }) - - describe('when the mousewheel event\'s target is a line', function () { - it('keeps the line on the DOM if it is scrolled off-screen', function () { - component.presenter.stoppedScrollingDelay = 3000 // account for slower build machines - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 20 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - let lineNode = componentNode.querySelector('.line') - let wheelEvent = new WheelEvent('mousewheel', { - wheelDeltaX: 0, - wheelDeltaY: -500 - }) - Object.defineProperty(wheelEvent, 'target', { - get: function () { - return lineNode - } - }) - componentNode.dispatchEvent(wheelEvent) - runAnimationFrames() - - expect(componentNode.contains(lineNode)).toBe(true) - }) - - it('does not set the mouseWheelScreenRow if scrolling horizontally', function () { - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 20 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - let lineNode = componentNode.querySelector('.line') - let wheelEvent = new WheelEvent('mousewheel', { - wheelDeltaX: 10, - wheelDeltaY: 0 - }) - Object.defineProperty(wheelEvent, 'target', { - get: function () { - return lineNode - } - }) - componentNode.dispatchEvent(wheelEvent) - runAnimationFrames() - - expect(component.presenter.mouseWheelScreenRow).toBe(null) - }) - - it('clears the mouseWheelScreenRow after a delay even if the event does not cause scrolling', async function () { - expect(wrapperNode.getScrollTop()).toBe(0) - let lineNode = componentNode.querySelector('.line') - let wheelEvent = new WheelEvent('mousewheel', { - wheelDeltaX: 0, - wheelDeltaY: 10 - }) - Object.defineProperty(wheelEvent, 'target', { - get: function () { - return lineNode - } - }) - componentNode.dispatchEvent(wheelEvent) - expect(wrapperNode.getScrollTop()).toBe(0) - expect(component.presenter.mouseWheelScreenRow).toBe(0) - - advanceClock(component.presenter.stoppedScrollingDelay) - expect(component.presenter.mouseWheelScreenRow).toBeNull() - }) - - it('does not preserve the line if it is on screen', function () { - let lineNode, lineNodes, wheelEvent - expect(componentNode.querySelectorAll('.line-number').length).toBe(14) - lineNodes = componentNode.querySelectorAll('.line') - expect(lineNodes.length).toBe(13) - lineNode = lineNodes[0] - wheelEvent = new WheelEvent('mousewheel', { - wheelDeltaX: 0, - wheelDeltaY: 100 - }) - Object.defineProperty(wheelEvent, 'target', { - get: function () { - return lineNode - } - }) - componentNode.dispatchEvent(wheelEvent) - expect(component.presenter.mouseWheelScreenRow).toBe(0) - editor.insertText('hello') - expect(componentNode.querySelectorAll('.line-number').length).toBe(14) - expect(componentNode.querySelectorAll('.line').length).toBe(13) - }) - }) - - describe('when the mousewheel event\'s target is a line number', function () { - it('keeps the line number on the DOM if it is scrolled off-screen', function () { - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 20 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - let lineNumberNode = componentNode.querySelectorAll('.line-number')[1] - let wheelEvent = new WheelEvent('mousewheel', { - wheelDeltaX: 0, - wheelDeltaY: -500 - }) - Object.defineProperty(wheelEvent, 'target', { - get: function () { - return lineNumberNode - } - }) - componentNode.dispatchEvent(wheelEvent) - runAnimationFrames() - - expect(componentNode.contains(lineNumberNode)).toBe(true) - }) - }) - - describe('when the mousewheel event\'s target is a block decoration', function () { - it('keeps it on the DOM if it is scrolled off-screen', function () { - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 20 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - let item = document.createElement("div") - item.style.width = "30px" - item.style.height = "30px" - item.className = "decoration-1" - editor.decorateMarker( - editor.markScreenPosition([0, 0], {invalidate: "never"}), - {type: "block", item: item} - ) - - runAnimationFrames() - - let wheelEvent = new WheelEvent('mousewheel', { - wheelDeltaX: 0, - wheelDeltaY: -500 - }) - Object.defineProperty(wheelEvent, 'target', { - get: function () { - return item - } - }) - componentNode.dispatchEvent(wheelEvent) - runAnimationFrames() - - expect(component.getTopmostDOMNode().contains(item)).toBe(true) - }) - }) - - describe('when the mousewheel event\'s target is an SVG element inside a block decoration', function () { - it('keeps the block decoration on the DOM if it is scrolled off-screen', function () { - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 20 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - const item = document.createElement('div') - const svgElement = document.createElementNS("http://www.w3.org/2000/svg", "svg") - item.appendChild(svgElement) - editor.decorateMarker( - editor.markScreenPosition([0, 0], {invalidate: "never"}), - {type: "block", item: item} - ) - - runAnimationFrames() - - let wheelEvent = new WheelEvent('mousewheel', { - wheelDeltaX: 0, - wheelDeltaY: -500 - }) - Object.defineProperty(wheelEvent, 'target', { - get: function () { - return svgElement - } - }) - componentNode.dispatchEvent(wheelEvent) - runAnimationFrames() - - expect(component.getTopmostDOMNode().contains(item)).toBe(true) - }) - }) - - it('only prevents the default action of the mousewheel event if it actually lead to scrolling', function () { - spyOn(WheelEvent.prototype, 'preventDefault').andCallThrough() - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 20 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - componentNode.dispatchEvent(new WheelEvent('mousewheel', { - wheelDeltaX: 0, - wheelDeltaY: 50 - })) - expect(wrapperNode.getScrollTop()).toBe(0) - expect(WheelEvent.prototype.preventDefault).not.toHaveBeenCalled() - componentNode.dispatchEvent(new WheelEvent('mousewheel', { - wheelDeltaX: 0, - wheelDeltaY: -3000 - })) - runAnimationFrames() - - let maxScrollTop = wrapperNode.getScrollTop() - expect(WheelEvent.prototype.preventDefault).toHaveBeenCalled() - WheelEvent.prototype.preventDefault.reset() - componentNode.dispatchEvent(new WheelEvent('mousewheel', { - wheelDeltaX: 0, - wheelDeltaY: -30 - })) - expect(wrapperNode.getScrollTop()).toBe(maxScrollTop) - expect(WheelEvent.prototype.preventDefault).not.toHaveBeenCalled() - componentNode.dispatchEvent(new WheelEvent('mousewheel', { - wheelDeltaX: 50, - wheelDeltaY: 0 - })) - expect(wrapperNode.getScrollLeft()).toBe(0) - expect(WheelEvent.prototype.preventDefault).not.toHaveBeenCalled() - componentNode.dispatchEvent(new WheelEvent('mousewheel', { - wheelDeltaX: -3000, - wheelDeltaY: 0 - })) - runAnimationFrames() - - let maxScrollLeft = wrapperNode.getScrollLeft() - expect(WheelEvent.prototype.preventDefault).toHaveBeenCalled() - WheelEvent.prototype.preventDefault.reset() - componentNode.dispatchEvent(new WheelEvent('mousewheel', { - wheelDeltaX: -30, - wheelDeltaY: 0 - })) - expect(wrapperNode.getScrollLeft()).toBe(maxScrollLeft) - expect(WheelEvent.prototype.preventDefault).not.toHaveBeenCalled() - }) - }) - - describe('input events', function () { - function buildTextInputEvent ({data, target}) { - let event = new Event('textInput') - event.data = data - Object.defineProperty(event, 'target', { - get: function () { - return target - } - }) - return event - } - - function buildKeydownEvent ({keyCode, target}) { - let event = new KeyboardEvent('keydown') - Object.defineProperty(event, 'keyCode', { - get: function () { - return keyCode - } - }) - Object.defineProperty(event, 'target', { - get: function () { - return target - } - }) - return event - } - - let inputNode - - beforeEach(function () { - inputNode = componentNode.querySelector('.hidden-input') - }) - - it('inserts the newest character in the input\'s value into the buffer', function () { - componentNode.dispatchEvent(buildTextInputEvent({ - data: 'x', - target: inputNode - })) - runAnimationFrames() - - expect(editor.lineTextForBufferRow(0)).toBe('xvar quicksort = function () {') - componentNode.dispatchEvent(buildTextInputEvent({ - data: 'y', - target: inputNode - })) - - expect(editor.lineTextForBufferRow(0)).toBe('xyvar quicksort = function () {') - }) - - it('replaces the last character if a keypress event is bracketed by keydown events with matching keyCodes, which occurs when the accented character menu is shown', function () { - componentNode.dispatchEvent(buildKeydownEvent({keyCode: 85, target: inputNode})) - componentNode.dispatchEvent(buildTextInputEvent({data: 'u', target: inputNode})) - componentNode.dispatchEvent(new KeyboardEvent('keypress')) - componentNode.dispatchEvent(buildKeydownEvent({keyCode: 85, target: inputNode})) - componentNode.dispatchEvent(new KeyboardEvent('keyup')) - runAnimationFrames() - - expect(editor.lineTextForBufferRow(0)).toBe('uvar quicksort = function () {') - inputNode.setSelectionRange(0, 1) - componentNode.dispatchEvent(buildTextInputEvent({ - data: 'ü', - target: inputNode - })) - runAnimationFrames() - - expect(editor.lineTextForBufferRow(0)).toBe('üvar quicksort = function () {') - }) - - it('does not handle input events when input is disabled', function () { - component.setInputEnabled(false) - componentNode.dispatchEvent(buildTextInputEvent({ - data: 'x', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () {') - runAnimationFrames() - expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () {') - }) - - it('groups events that occur close together in time into single undo entries', function () { - let currentTime = 0 - spyOn(Date, 'now').andCallFake(function () { - return currentTime - }) - editor.update({undoGroupingInterval: 100}) - editor.setText('') - componentNode.dispatchEvent(buildTextInputEvent({ - data: 'x', - target: inputNode - })) - currentTime += 99 - componentNode.dispatchEvent(buildTextInputEvent({ - data: 'y', - target: inputNode - })) - currentTime += 99 - componentNode.dispatchEvent(new CustomEvent('editor:duplicate-lines', { - bubbles: true, - cancelable: true - })) - currentTime += 101 - componentNode.dispatchEvent(new CustomEvent('editor:duplicate-lines', { - bubbles: true, - cancelable: true - })) - expect(editor.getText()).toBe('xy\nxy\nxy') - componentNode.dispatchEvent(new CustomEvent('core:undo', { - bubbles: true, - cancelable: true - })) - expect(editor.getText()).toBe('xy\nxy') - componentNode.dispatchEvent(new CustomEvent('core:undo', { - bubbles: true, - cancelable: true - })) - expect(editor.getText()).toBe('') - }) - - describe('when IME composition is used to insert international characters', function () { - function buildIMECompositionEvent (event, {data, target} = {}) { - event = new Event(event) - event.data = data - Object.defineProperty(event, 'target', { - get: function () { - return target - } - }) - return event - } - - let inputNode - - beforeEach(function () { - inputNode = componentNode.querySelector('.hidden-input') - }) - - describe('when nothing is selected', function () { - it('inserts the chosen completion', function () { - componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', { - target: inputNode - })) - componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { - data: 's', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('svar quicksort = function () {') - componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { - data: 'sd', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('sdvar quicksort = function () {') - componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', { - target: inputNode - })) - componentNode.dispatchEvent(buildTextInputEvent({ - data: '速度', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('速度var quicksort = function () {') - }) - - it('reverts back to the original text when the completion helper is dismissed', function () { - componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', { - target: inputNode - })) - componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { - data: 's', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('svar quicksort = function () {') - componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { - data: 'sd', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('sdvar quicksort = function () {') - componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', { - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () {') - }) - - it('allows multiple accented character to be inserted with the \' on a US international layout', function () { - inputNode.value = '\'' - inputNode.setSelectionRange(0, 1) - componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', { - target: inputNode - })) - componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { - data: '\'', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('\'var quicksort = function () {') - componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', { - target: inputNode - })) - componentNode.dispatchEvent(buildTextInputEvent({ - data: 'á', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('ávar quicksort = function () {') - inputNode.value = '\'' - inputNode.setSelectionRange(0, 1) - componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', { - target: inputNode - })) - componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { - data: '\'', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('á\'var quicksort = function () {') - componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', { - target: inputNode - })) - componentNode.dispatchEvent(buildTextInputEvent({ - data: 'á', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('áávar quicksort = function () {') - }) - }) - - describe('when a string is selected', function () { - beforeEach(function () { - editor.setSelectedBufferRanges([[[0, 4], [0, 9]], [[0, 16], [0, 19]]]) - }) - - it('inserts the chosen completion', function () { - componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', { - target: inputNode - })) - componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { - data: 's', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('var ssort = sction () {') - componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { - data: 'sd', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('var sdsort = sdction () {') - componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', { - target: inputNode - })) - componentNode.dispatchEvent(buildTextInputEvent({ - data: '速度', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('var 速度sort = 速度ction () {') - }) - - it('reverts back to the original text when the completion helper is dismissed', function () { - componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', { - target: inputNode - })) - componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { - data: 's', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('var ssort = sction () {') - componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { - data: 'sd', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('var sdsort = sdction () {') - componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', { - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () {') - }) - }) - }) - }) - - describe('commands', function () { - describe('editor:consolidate-selections', function () { - it('consolidates selections on the editor model, aborting the key binding if there is only one selection', function () { - spyOn(editor, 'consolidateSelections').andCallThrough() - let event = new CustomEvent('editor:consolidate-selections', { - bubbles: true, - cancelable: true - }) - event.abortKeyBinding = jasmine.createSpy('event.abortKeyBinding') - componentNode.dispatchEvent(event) - expect(editor.consolidateSelections).toHaveBeenCalled() - expect(event.abortKeyBinding).toHaveBeenCalled() - }) - }) - }) - - describe('when decreasing the fontSize', function () { - it('decreases the widths of the korean char, the double width char and the half width char', function () { - originalDefaultCharWidth = editor.getDefaultCharWidth() - koreanDefaultCharWidth = editor.getKoreanCharWidth() - doubleWidthDefaultCharWidth = editor.getDoubleWidthCharWidth() - halfWidthDefaultCharWidth = editor.getHalfWidthCharWidth() - component.setFontSize(10) - runAnimationFrames() - expect(editor.getDefaultCharWidth()).toBeLessThan(originalDefaultCharWidth) - expect(editor.getKoreanCharWidth()).toBeLessThan(koreanDefaultCharWidth) - expect(editor.getDoubleWidthCharWidth()).toBeLessThan(doubleWidthDefaultCharWidth) - expect(editor.getHalfWidthCharWidth()).toBeLessThan(halfWidthDefaultCharWidth) - }) - }) - - describe('when increasing the fontSize', function() { - it('increases the widths of the korean char, the double width char and the half width char', function () { - originalDefaultCharWidth = editor.getDefaultCharWidth() - koreanDefaultCharWidth = editor.getKoreanCharWidth() - doubleWidthDefaultCharWidth = editor.getDoubleWidthCharWidth() - halfWidthDefaultCharWidth = editor.getHalfWidthCharWidth() - component.setFontSize(25) - runAnimationFrames() - expect(editor.getDefaultCharWidth()).toBeGreaterThan(originalDefaultCharWidth) - expect(editor.getKoreanCharWidth()).toBeGreaterThan(koreanDefaultCharWidth) - expect(editor.getDoubleWidthCharWidth()).toBeGreaterThan(doubleWidthDefaultCharWidth) - expect(editor.getHalfWidthCharWidth()).toBeGreaterThan(halfWidthDefaultCharWidth) - }) - }) - - describe('hiding and showing the editor', function () { - describe('when the editor is hidden when it is mounted', function () { - it('defers measurement and rendering until the editor becomes visible', function () { - wrapperNode.remove() - let hiddenParent = document.createElement('div') - hiddenParent.style.display = 'none' - contentNode.appendChild(hiddenParent) - wrapperNode = new TextEditorElement() - wrapperNode.tileSize = TILE_SIZE - wrapperNode.initialize(editor, atom) - hiddenParent.appendChild(wrapperNode) - component = wrapperNode.component - componentNode = component.getDomNode() - expect(componentNode.querySelectorAll('.line').length).toBe(0) - hiddenParent.style.display = 'block' - expect(componentNode.querySelectorAll('.line').length).toBeGreaterThan(0) - }) - }) - - describe('when the lineHeight changes while the editor is hidden', function () { - it('does not attempt to measure the lineHeightInPixels until the editor becomes visible again', function () { - wrapperNode.style.display = 'none' - component.checkForVisibilityChange() - let initialLineHeightInPixels = editor.getLineHeightInPixels() - component.setLineHeight(2) - expect(editor.getLineHeightInPixels()).toBe(initialLineHeightInPixels) - wrapperNode.style.display = '' - component.checkForVisibilityChange() - expect(editor.getLineHeightInPixels()).not.toBe(initialLineHeightInPixels) - }) - }) - - describe('when the fontSize changes while the editor is hidden', function () { - it('does not attempt to measure the lineHeightInPixels or defaultCharWidth until the editor becomes visible again', function () { - wrapperNode.style.display = 'none' - component.checkForVisibilityChange() - let initialLineHeightInPixels = editor.getLineHeightInPixels() - let initialCharWidth = editor.getDefaultCharWidth() - component.setFontSize(22) - expect(editor.getLineHeightInPixels()).toBe(initialLineHeightInPixels) - expect(editor.getDefaultCharWidth()).toBe(initialCharWidth) - wrapperNode.style.display = '' - component.checkForVisibilityChange() - expect(editor.getLineHeightInPixels()).not.toBe(initialLineHeightInPixels) - expect(editor.getDefaultCharWidth()).not.toBe(initialCharWidth) - }) - - it('does not re-measure character widths until the editor is shown again', function () { - wrapperNode.style.display = 'none' - component.checkForVisibilityChange() - component.setFontSize(22) - editor.getBuffer().insert([0, 0], 'a') - wrapperNode.style.display = '' - component.checkForVisibilityChange() - editor.setCursorBufferPosition([0, Infinity]) - runAnimationFrames() - let cursorLeft = componentNode.querySelector('.cursor').getBoundingClientRect().left - let line0Right = componentNode.querySelector('.line > span:last-child').getBoundingClientRect().right - expect(cursorLeft).toBeCloseTo(line0Right, 0) - }) - }) - - describe('when the fontFamily changes while the editor is hidden', function () { - it('does not attempt to measure the defaultCharWidth until the editor becomes visible again', function () { - wrapperNode.style.display = 'none' - component.checkForVisibilityChange() - let initialLineHeightInPixels = editor.getLineHeightInPixels() - let initialCharWidth = editor.getDefaultCharWidth() - component.setFontFamily('serif') - expect(editor.getDefaultCharWidth()).toBe(initialCharWidth) - wrapperNode.style.display = '' - component.checkForVisibilityChange() - expect(editor.getDefaultCharWidth()).not.toBe(initialCharWidth) - }) - - it('does not re-measure character widths until the editor is shown again', function () { - wrapperNode.style.display = 'none' - component.checkForVisibilityChange() - component.setFontFamily('serif') - wrapperNode.style.display = '' - component.checkForVisibilityChange() - editor.setCursorBufferPosition([0, Infinity]) - runAnimationFrames() - let cursorLeft = componentNode.querySelector('.cursor').getBoundingClientRect().left - let line0Right = componentNode.querySelector('.line > span:last-child').getBoundingClientRect().right - expect(cursorLeft).toBeCloseTo(line0Right, 0) - }) - }) - - describe('when stylesheets change while the editor is hidden', function () { - afterEach(function () { - atom.themes.removeStylesheet('test') - }) - - it('does not re-measure character widths until the editor is shown again', function () { - atom.config.set('editor.fontFamily', 'sans-serif') - wrapperNode.style.display = 'none' - component.checkForVisibilityChange() - atom.themes.applyStylesheet('test', '.syntax--function.syntax--js {\n font-weight: bold;\n}') - wrapperNode.style.display = '' - component.checkForVisibilityChange() - editor.setCursorBufferPosition([0, Infinity]) - runAnimationFrames() - let cursorLeft = componentNode.querySelector('.cursor').getBoundingClientRect().left - let line0Right = componentNode.querySelector('.line > span:last-child').getBoundingClientRect().right - expect(cursorLeft).toBeCloseTo(line0Right, 0) - }) - }) - }) - - describe('soft wrapping', function () { - beforeEach(function () { - editor.setSoftWrapped(true) - runAnimationFrames() - }) - - it('updates the wrap location when the editor is resized', function () { - let newHeight = 4 * editor.getLineHeightInPixels() + 'px' - expect(parseInt(newHeight)).toBeLessThan(wrapperNode.offsetHeight) - wrapperNode.style.height = newHeight - editor.update({autoHeight: false}) - runAnimationFrames() - - expect(componentNode.querySelectorAll('.line')).toHaveLength(7) - let gutterWidth = componentNode.querySelector('.gutter').offsetWidth - componentNode.style.width = gutterWidth + 14 * charWidth + wrapperNode.getVerticalScrollbarWidth() + 'px' - runAnimationFrames() - expect(componentNode.querySelector('.line').textContent).toBe('var quicksort ') - }) - - it('accounts for the scroll view\'s padding when determining the wrap location', function () { - let scrollViewNode = componentNode.querySelector('.scroll-view') - scrollViewNode.style.paddingLeft = 20 + 'px' - componentNode.style.width = 30 * charWidth + 'px' - runAnimationFrames() - expect(component.lineNodeForScreenRow(0).textContent).toBe('var quicksort = ') - }) - }) - - describe('default decorations', function () { - it('applies .cursor-line decorations for line numbers overlapping selections', function () { - editor.setCursorScreenPosition([4, 4]) - runAnimationFrames() - - expect(lineNumberHasClass(3, 'cursor-line')).toBe(false) - expect(lineNumberHasClass(4, 'cursor-line')).toBe(true) - expect(lineNumberHasClass(5, 'cursor-line')).toBe(false) - editor.setSelectedScreenRange([[3, 4], [4, 4]]) - runAnimationFrames() - - expect(lineNumberHasClass(3, 'cursor-line')).toBe(true) - expect(lineNumberHasClass(4, 'cursor-line')).toBe(true) - editor.setSelectedScreenRange([[3, 4], [4, 0]]) - runAnimationFrames() - - expect(lineNumberHasClass(3, 'cursor-line')).toBe(true) - expect(lineNumberHasClass(4, 'cursor-line')).toBe(false) - }) - - it('does not apply .cursor-line to the last line of a selection if it\'s empty', function () { - editor.setSelectedScreenRange([[3, 4], [5, 0]]) - runAnimationFrames() - expect(lineNumberHasClass(3, 'cursor-line')).toBe(true) - expect(lineNumberHasClass(4, 'cursor-line')).toBe(true) - expect(lineNumberHasClass(5, 'cursor-line')).toBe(false) - }) - - it('applies .cursor-line decorations for lines containing the cursor in non-empty selections', function () { - editor.setCursorScreenPosition([4, 4]) - runAnimationFrames() - - expect(lineHasClass(3, 'cursor-line')).toBe(false) - expect(lineHasClass(4, 'cursor-line')).toBe(true) - expect(lineHasClass(5, 'cursor-line')).toBe(false) - editor.setSelectedScreenRange([[3, 4], [4, 4]]) - runAnimationFrames() - - expect(lineHasClass(2, 'cursor-line')).toBe(false) - expect(lineHasClass(3, 'cursor-line')).toBe(false) - expect(lineHasClass(4, 'cursor-line')).toBe(false) - expect(lineHasClass(5, 'cursor-line')).toBe(false) - }) - - it('applies .cursor-line-no-selection to line numbers for rows containing the cursor when the selection is empty', function () { - editor.setCursorScreenPosition([4, 4]) - runAnimationFrames() - - expect(lineNumberHasClass(4, 'cursor-line-no-selection')).toBe(true) - editor.setSelectedScreenRange([[3, 4], [4, 4]]) - runAnimationFrames() - - expect(lineNumberHasClass(4, 'cursor-line-no-selection')).toBe(false) - }) - }) - - describe('height', function () { - describe('when autoHeight is true', function () { - it('assigns the editor\'s height to based on its contents', function () { - jasmine.attachToDOM(wrapperNode) - expect(editor.getAutoHeight()).toBe(true) - expect(wrapperNode.offsetHeight).toBe(editor.getLineHeightInPixels() * editor.getScreenLineCount()) - editor.insertText('\n\n\n') - runAnimationFrames() - expect(wrapperNode.offsetHeight).toBe(editor.getLineHeightInPixels() * editor.getScreenLineCount()) - }) - }) - - describe('when autoHeight is false', function () { - it('does not assign the height of the editor, instead allowing content to scroll', function () { - jasmine.attachToDOM(wrapperNode) - editor.update({autoHeight: false}) - wrapperNode.style.height = '200px' - expect(wrapperNode.offsetHeight).toBe(200) - editor.insertText('\n\n\n') - runAnimationFrames() - expect(wrapperNode.offsetHeight).toBe(200) - }) - }) - - describe('when autoHeight is not assigned on the editor', function () { - it('implicitly assigns autoHeight to true and emits a deprecation warning if the editor has its height assigned via an inline style', function () { - editor = new TextEditor() - element = editor.getElement() - element.setUpdatedSynchronously(false) - element.style.height = '200px' - - spyOn(Grim, 'deprecate') - jasmine.attachToDOM(element) - - expect(element.offsetHeight).toBe(200) - expect(element.querySelector('.editor-contents--private').offsetHeight).toBe(200) - expect(Grim.deprecate.callCount).toBe(1) - expect(Grim.deprecate.argsForCall[0][0]).toMatch(/inline style/) - }) - - it('implicitly assigns autoHeight to true and emits a deprecation warning if the editor has its height assigned via position absolute with an assigned top and bottom', function () { - editor = new TextEditor() - element = editor.getElement() - element.setUpdatedSynchronously(false) - parentElement = document.createElement('div') - parentElement.style.position = 'absolute' - parentElement.style.height = '200px' - element.style.position = 'absolute' - element.style.top = '0px' - element.style.bottom = '0px' - parentElement.appendChild(element) - - spyOn(Grim, 'deprecate') - - jasmine.attachToDOM(parentElement) - element.component.measureDimensions() - - expect(element.offsetHeight).toBe(200) - expect(element.querySelector('.editor-contents--private').offsetHeight).toBe(200) - expect(Grim.deprecate.callCount).toBe(1) - expect(Grim.deprecate.argsForCall[0][0]).toMatch(/absolute/) - }) - }) - - describe('when the wrapper view has an explicit height', function () { - it('does not assign a height on the component node', function () { - wrapperNode.style.height = '200px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - expect(componentNode.style.height).toBe('') - }) - }) - - describe('when the wrapper view does not have an explicit height', function () { - it('assigns a height on the component node based on the editor\'s content', function () { - expect(wrapperNode.style.height).toBe('') - expect(componentNode.style.height).toBe(editor.getScreenLineCount() * lineHeightInPixels + 'px') - }) - }) - }) - - describe('width', function () { - it('sizes the editor element according to the content width when auto width is true, or according to the container width otherwise', function () { - contentNode.style.width = '600px' - component.measureDimensions() - editor.setText("abcdefghi") - runAnimationFrames() - expect(wrapperNode.offsetWidth).toBe(contentNode.offsetWidth) - - editor.update({autoWidth: true}) - runAnimationFrames() - const editorWidth1 = wrapperNode.offsetWidth - expect(editorWidth1).toBeGreaterThan(0) - expect(editorWidth1).toBeLessThan(contentNode.offsetWidth) - - editor.setText("abcdefghijkl") - editor.update({autoWidth: true}) - runAnimationFrames() - const editorWidth2 = wrapperNode.offsetWidth - expect(editorWidth2).toBeGreaterThan(editorWidth1) - expect(editorWidth2).toBeLessThan(contentNode.offsetWidth) - - editor.update({autoWidth: false}) - runAnimationFrames() - expect(wrapperNode.offsetWidth).toBe(contentNode.offsetWidth) - }) - }) - - describe('when the "mini" property is true', function () { - beforeEach(function () { - editor.setMini(true) - runAnimationFrames() - }) - - it('does not render the gutter', function () { - expect(componentNode.querySelector('.gutter')).toBeNull() - }) - - it('adds the "mini" class to the wrapper view', function () { - expect(wrapperNode.classList.contains('mini')).toBe(true) - }) - - it('does not have an opaque background on lines', function () { - expect(component.linesComponent.getDomNode().getAttribute('style')).not.toContain('background-color') - }) - - it('does not render invisible characters', function () { - editor.update({ - showInvisibles: true, - invisibles: {eol: 'E'} - }) - expect(component.lineNodeForScreenRow(0).textContent).toBe('var quicksort = function () {') - }) - - it('does not assign an explicit line-height on the editor contents', function () { - expect(componentNode.style.lineHeight).toBe('') - }) - - it('does not apply cursor-line decorations', function () { - expect(component.lineNodeForScreenRow(0).classList.contains('cursor-line')).toBe(false) - }) - }) - - describe('when placholderText is specified', function () { - it('renders the placeholder text when the buffer is empty', function () { - editor.setPlaceholderText('Hello World') - expect(componentNode.querySelector('.placeholder-text')).toBeNull() - editor.setText('') - runAnimationFrames() - - expect(componentNode.querySelector('.placeholder-text').textContent).toBe('Hello World') - editor.setText('hey') - runAnimationFrames() - - expect(componentNode.querySelector('.placeholder-text')).toBeNull() - }) - }) - - describe('grammar data attributes', function () { - it('adds and updates the grammar data attribute based on the current grammar', function () { - expect(wrapperNode.dataset.grammar).toBe('source js') - editor.setGrammar(atom.grammars.nullGrammar) - expect(wrapperNode.dataset.grammar).toBe('text plain null-grammar') - }) - }) - - describe('encoding data attributes', function () { - it('adds and updates the encoding data attribute based on the current encoding', function () { - expect(wrapperNode.dataset.encoding).toBe('utf8') - editor.setEncoding('utf16le') - expect(wrapperNode.dataset.encoding).toBe('utf16le') - }) - }) - - describe('detaching and reattaching the editor (regression)', function () { - it('does not throw an exception', function () { - wrapperNode.remove() - jasmine.attachToDOM(wrapperNode) - atom.commands.dispatch(wrapperNode, 'core:move-right') - expect(editor.getCursorBufferPosition()).toEqual([0, 1]) - }) - }) - - describe('autoscroll', function () { - beforeEach(function () { - editor.setVerticalScrollMargin(2) - editor.setHorizontalScrollMargin(2) - component.setLineHeight('10px') - component.setFontSize(17) - component.measureDimensions() - runAnimationFrames() - - wrapperNode.style.width = 55 + component.getGutterWidth() + 'px' - wrapperNode.style.height = '55px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - component.presenter.setHorizontalScrollbarHeight(0) - component.presenter.setVerticalScrollbarWidth(0) - runAnimationFrames() - }) - - describe('when selecting buffer ranges', function () { - it('autoscrolls the selection if it is last unless the "autoscroll" option is false', function () { - expect(wrapperNode.getScrollTop()).toBe(0) - editor.setSelectedBufferRange([[5, 6], [6, 8]]) - runAnimationFrames() - - let right = wrapperNode.pixelPositionForBufferPosition([6, 8 + editor.getHorizontalScrollMargin()]).left - expect(wrapperNode.getScrollBottom()).toBe((7 + editor.getVerticalScrollMargin()) * 10) - expect(wrapperNode.getScrollRight()).toBeCloseTo(right, 0) - editor.setSelectedBufferRange([[0, 0], [0, 0]]) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - expect(wrapperNode.getScrollLeft()).toBe(0) - editor.setSelectedBufferRange([[6, 6], [6, 8]]) - runAnimationFrames() - - expect(wrapperNode.getScrollBottom()).toBe((7 + editor.getVerticalScrollMargin()) * 10) - expect(wrapperNode.getScrollRight()).toBeCloseTo(right, 0) - }) - }) - - describe('when adding selections for buffer ranges', function () { - it('autoscrolls to the added selection if needed', function () { - editor.addSelectionForBufferRange([[8, 10], [8, 15]]) - runAnimationFrames() - - let right = wrapperNode.pixelPositionForBufferPosition([8, 15]).left - expect(wrapperNode.getScrollBottom()).toBe((9 * 10) + (2 * 10)) - expect(wrapperNode.getScrollRight()).toBeCloseTo(right + 2 * 10, 0) - }) - }) - - describe('when selecting lines containing cursors', function () { - it('autoscrolls to the selection', function () { - editor.setCursorScreenPosition([5, 6]) - runAnimationFrames() - - wrapperNode.scrollToTop() - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.selectLinesContainingCursors() - runAnimationFrames() - - expect(wrapperNode.getScrollBottom()).toBe((7 + editor.getVerticalScrollMargin()) * 10) - }) - }) - - describe('when inserting text', function () { - describe('when there are multiple empty selections on different lines', function () { - it('autoscrolls to the last cursor', function () { - editor.setCursorScreenPosition([1, 2], { - autoscroll: false - }) - runAnimationFrames() - - editor.addCursorAtScreenPosition([10, 4], { - autoscroll: false - }) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.insertText('a') - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(75) - }) - }) - }) - - describe('when scrolled to cursor position', function () { - it('scrolls the last cursor into view, centering around the cursor if possible and the "center" option is not false', function () { - editor.setCursorScreenPosition([8, 8], { - autoscroll: false - }) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - expect(wrapperNode.getScrollLeft()).toBe(0) - editor.scrollToCursorPosition() - runAnimationFrames() - - let right = wrapperNode.pixelPositionForScreenPosition([8, 9 + editor.getHorizontalScrollMargin()]).left - expect(wrapperNode.getScrollTop()).toBe((8.8 * 10) - 30) - expect(wrapperNode.getScrollBottom()).toBe((8.3 * 10) + 30) - expect(wrapperNode.getScrollRight()).toBeCloseTo(right, 0) - wrapperNode.setScrollTop(0) - editor.scrollToCursorPosition({ - center: false - }) - expect(wrapperNode.getScrollTop()).toBe((7.8 - editor.getVerticalScrollMargin()) * 10) - expect(wrapperNode.getScrollBottom()).toBe((9.3 + editor.getVerticalScrollMargin()) * 10) - }) - }) - - describe('moving cursors', function () { - it('scrolls down when the last cursor gets closer than ::verticalScrollMargin to the bottom of the editor', function () { - expect(wrapperNode.getScrollTop()).toBe(0) - expect(wrapperNode.getScrollBottom()).toBe(5.5 * 10) - editor.setCursorScreenPosition([2, 0]) - runAnimationFrames() - - expect(wrapperNode.getScrollBottom()).toBe(5.5 * 10) - editor.moveDown() - runAnimationFrames() - - expect(wrapperNode.getScrollBottom()).toBe(6 * 10) - editor.moveDown() - runAnimationFrames() - - expect(wrapperNode.getScrollBottom()).toBe(7 * 10) - }) - - it('scrolls up when the last cursor gets closer than ::verticalScrollMargin to the top of the editor', function () { - editor.setCursorScreenPosition([11, 0]) - runAnimationFrames() - - wrapperNode.setScrollBottom(wrapperNode.getScrollHeight()) - runAnimationFrames() - - editor.moveUp() - runAnimationFrames() - - expect(wrapperNode.getScrollBottom()).toBe(wrapperNode.getScrollHeight()) - editor.moveUp() - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(7 * 10) - editor.moveUp() - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(6 * 10) - }) - - it('scrolls right when the last cursor gets closer than ::horizontalScrollMargin to the right of the editor', function () { - expect(wrapperNode.getScrollLeft()).toBe(0) - expect(wrapperNode.getScrollRight()).toBe(5.5 * 10) - editor.setCursorScreenPosition([0, 2]) - runAnimationFrames() - - expect(wrapperNode.getScrollRight()).toBe(5.5 * 10) - editor.moveRight() - runAnimationFrames() - - let margin = component.presenter.getHorizontalScrollMarginInPixels() - let right = wrapperNode.pixelPositionForScreenPosition([0, 4]).left + margin - expect(wrapperNode.getScrollRight()).toBeCloseTo(right, 0) - editor.moveRight() - runAnimationFrames() - - right = wrapperNode.pixelPositionForScreenPosition([0, 5]).left + margin - expect(wrapperNode.getScrollRight()).toBeCloseTo(right, 0) - }) - - it('scrolls left when the last cursor gets closer than ::horizontalScrollMargin to the left of the editor', function () { - wrapperNode.setScrollRight(wrapperNode.getScrollWidth()) - runAnimationFrames() - - expect(wrapperNode.getScrollRight()).toBe(wrapperNode.getScrollWidth()) - editor.setCursorScreenPosition([6, 62], { - autoscroll: false - }) - runAnimationFrames() - - editor.moveLeft() - runAnimationFrames() - - let margin = component.presenter.getHorizontalScrollMarginInPixels() - let left = wrapperNode.pixelPositionForScreenPosition([6, 61]).left - margin - expect(wrapperNode.getScrollLeft()).toBeCloseTo(left, 0) - editor.moveLeft() - runAnimationFrames() - - left = wrapperNode.pixelPositionForScreenPosition([6, 60]).left - margin - expect(wrapperNode.getScrollLeft()).toBeCloseTo(left, 0) - }) - - it('scrolls down when inserting lines makes the document longer than the editor\'s height', function () { - editor.setCursorScreenPosition([13, Infinity]) - editor.insertNewline() - runAnimationFrames() - - expect(wrapperNode.getScrollBottom()).toBe(14 * 10) - editor.insertNewline() - runAnimationFrames() - - expect(wrapperNode.getScrollBottom()).toBe(15 * 10) - }) - - it('autoscrolls to the cursor when it moves due to undo', function () { - editor.insertText('abc') - wrapperNode.setScrollTop(Infinity) - runAnimationFrames() - - editor.undo() - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - }) - - it('does not scroll when the cursor moves into the visible area', function () { - editor.setCursorBufferPosition([0, 0]) - runAnimationFrames() - - wrapperNode.setScrollTop(40) - runAnimationFrames() - - editor.setCursorBufferPosition([6, 0]) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(40) - }) - - it('honors the autoscroll option on cursor and selection manipulation methods', function () { - expect(wrapperNode.getScrollTop()).toBe(0) - editor.addCursorAtScreenPosition([11, 11], {autoscroll: false}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.addCursorAtBufferPosition([11, 11], {autoscroll: false}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.setCursorScreenPosition([11, 11], {autoscroll: false}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.setCursorBufferPosition([11, 11], {autoscroll: false}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.addSelectionForBufferRange([[11, 11], [11, 11]], {autoscroll: false}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.addSelectionForScreenRange([[11, 11], [11, 12]], {autoscroll: false}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.setSelectedBufferRange([[11, 0], [11, 1]], {autoscroll: false}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.setSelectedScreenRange([[11, 0], [11, 6]], {autoscroll: false}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.clearSelections({autoscroll: false}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.addSelectionForScreenRange([[0, 0], [0, 4]]) - runAnimationFrames() - - editor.getCursors()[0].setScreenPosition([11, 11], {autoscroll: true}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBeGreaterThan(0) - editor.getCursors()[0].setBufferPosition([0, 0], {autoscroll: true}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.getSelections()[0].setScreenRange([[11, 0], [11, 4]], {autoscroll: true}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBeGreaterThan(0) - editor.getSelections()[0].setBufferRange([[0, 0], [0, 4]], {autoscroll: true}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - }) - }) - }) - - describe('::getVisibleRowRange()', function () { - beforeEach(function () { - wrapperNode.style.height = lineHeightInPixels * 8 + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - }) - - it('returns the first and the last visible rows', function () { - component.setScrollTop(0) - runAnimationFrames() - expect(component.getVisibleRowRange()).toEqual([0, 9]) - }) - - it('ends at last buffer row even if there\'s more space available', function () { - wrapperNode.style.height = lineHeightInPixels * 13 + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - component.setScrollTop(60) - runAnimationFrames() - - expect(component.getVisibleRowRange()).toEqual([0, 13]) - }) - }) - - describe('::pixelPositionForScreenPosition()', () => { - it('returns the correct horizontal position, even if it is on a row that has not yet been rendered (regression)', () => { - editor.setTextInBufferRange([[5, 0], [6, 0]], 'hello world\n') - expect(wrapperNode.pixelPositionForScreenPosition([5, Infinity]).left).toBeGreaterThan(0) - }) - }) - - describe('middle mouse paste on Linux', function () { - let originalPlatform - - beforeEach(function () { - originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - value: 'linux' - }) - }) - - afterEach(function () { - Object.defineProperty(process, 'platform', { - value: originalPlatform - }) - }) - - it('pastes the previously selected text at the clicked location', async function () { - let clipboardWrittenTo = false - spyOn(require('electron').ipcRenderer, 'send').andCallFake(function (eventName, selectedText) { - if (eventName === 'write-text-to-selection-clipboard') { - require('../src/safe-clipboard').writeText(selectedText, 'selection') - clipboardWrittenTo = true - } - }) - atom.clipboard.write('') - component.trackSelectionClipboard() - editor.setSelectedBufferRange([[1, 6], [1, 10]]) - advanceClock(0) - - componentNode.querySelector('.scroll-view').dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([10, 0]), { - button: 1 - })) - componentNode.querySelector('.scroll-view').dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenPosition([10, 0]), { - which: 2 - })) - expect(atom.clipboard.read()).toBe('sort') - expect(editor.lineTextForBufferRow(10)).toBe('sort') - }) - - it('pastes the previously selected text at the clicked location, left clicks do not interfere', async function () { - let clipboardWrittenTo = false - spyOn(require('electron').ipcRenderer, 'send').andCallFake(function (eventName, selectedText) { - if (eventName === 'write-text-to-selection-clipboard') { - require('../src/safe-clipboard').writeText(selectedText, 'selection') - clipboardWrittenTo = true - } - }) - atom.clipboard.write('') - component.trackSelectionClipboard() - editor.setSelectedBufferRange([[1, 6], [1, 10]]) - advanceClock(0) - - componentNode.querySelector('.scroll-view').dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([10, 0]), { - button: 0 - })) - componentNode.querySelector('.scroll-view').dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenPosition([10, 0]), { - which: 1 - })) - componentNode.querySelector('.scroll-view').dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([10, 0]), { - button: 1 - })) - componentNode.querySelector('.scroll-view').dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenPosition([10, 0]), { - which: 2 - })) - expect(atom.clipboard.read()).toBe('sort') - expect(editor.lineTextForBufferRow(10)).toBe('sort') - }) +const TextEditorComponent = require('../src/text-editor-component') +const TextEditor = require('../src/text-editor') +const fs = require('fs') +const path = require('path') + +const SAMPLE_TEXT = fs.readFileSync(path.join(__dirname, 'fixtures', 'sample.js'), 'utf8') +const NBSP_CHARACTER = '\u00a0' + +describe('TextEditorComponent', () => { + let editor + + beforeEach(() => { + jasmine.useRealClock() + editor = new TextEditor() + editor.setText(SAMPLE_TEXT) + }) + + it('renders lines and line numbers for the visible region', async () => { + const component = new TextEditorComponent({model: editor, rowsPerTile: 3}) + const {element} = component + + element.style.width = '800px' + element.style.height = '600px' + jasmine.attachToDOM(element) + expect(element.querySelectorAll('.line-number').length).toBe(13) + expect(element.querySelectorAll('.line').length).toBe(13) + + element.style.height = 4 * component.measurements.lineHeight + 'px' + await component.getNextUpdatePromise() + expect(element.querySelectorAll('.line-number').length).toBe(9) + expect(element.querySelectorAll('.line').length).toBe(9) + + component.refs.scroller.scrollTop = 5 * component.measurements.lineHeight + await component.getNextUpdatePromise() + + // After scrolling down beyond > 3 rows, the order of line numbers and lines + // in the DOM is a bit weird because the first tile is recycled to the bottom + // when it is scrolled out of view + expect(Array.from(element.querySelectorAll('.line-number')).map(element => element.textContent.trim())).toEqual([ + '10', '11', '12', '4', '5', '6', '7', '8', '9' + ]) + expect(Array.from(element.querySelectorAll('.line')).map(element => element.textContent)).toEqual([ + editor.lineTextForScreenRow(9), + ' ', // this line is blank in the model, but we render a space to prevent the line from collapsing vertically + editor.lineTextForScreenRow(11), + editor.lineTextForScreenRow(3), + editor.lineTextForScreenRow(4), + editor.lineTextForScreenRow(5), + editor.lineTextForScreenRow(6), + editor.lineTextForScreenRow(7), + editor.lineTextForScreenRow(8) + ]) + + component.refs.scroller.scrollTop = 2.5 * component.measurements.lineHeight + await component.getNextUpdatePromise() + expect(Array.from(element.querySelectorAll('.line-number')).map(element => element.textContent.trim())).toEqual([ + '1', '2', '3', '4', '5', '6', '7', '8', '9' + ]) + expect(Array.from(element.querySelectorAll('.line')).map(element => element.textContent)).toEqual([ + editor.lineTextForScreenRow(0), + editor.lineTextForScreenRow(1), + editor.lineTextForScreenRow(2), + editor.lineTextForScreenRow(3), + editor.lineTextForScreenRow(4), + editor.lineTextForScreenRow(5), + editor.lineTextForScreenRow(6), + editor.lineTextForScreenRow(7), + editor.lineTextForScreenRow(8) + ]) }) - - function buildMouseEvent (type, ...propertiesObjects) { - let properties = extend({ - bubbles: true, - cancelable: true - }, ...propertiesObjects) - - if (properties.detail == null) { - properties.detail = 1 - } - - let event = new MouseEvent(type, properties) - if (properties.which != null) { - Object.defineProperty(event, 'which', { - get: function () { - return properties.which - } - }) - } - if (properties.target != null) { - Object.defineProperty(event, 'target', { - get: function () { - return properties.target - } - }) - Object.defineProperty(event, 'srcObject', { - get: function () { - return properties.target - } - }) - } - return event - } - - function clientCoordinatesForScreenPosition (screenPosition) { - let clientX, clientY, positionOffset, scrollViewClientRect - positionOffset = wrapperNode.pixelPositionForScreenPosition(screenPosition) - scrollViewClientRect = componentNode.querySelector('.scroll-view').getBoundingClientRect() - clientX = scrollViewClientRect.left + positionOffset.left - wrapperNode.getScrollLeft() - clientY = scrollViewClientRect.top + positionOffset.top - wrapperNode.getScrollTop() - return { - clientX: clientX, - clientY: clientY - } - } - - function clientCoordinatesForScreenRowInGutter (screenRow) { - let clientX, clientY, gutterClientRect, positionOffset - positionOffset = wrapperNode.pixelPositionForScreenPosition([screenRow, Infinity]) - gutterClientRect = componentNode.querySelector('.gutter').getBoundingClientRect() - clientX = gutterClientRect.left + positionOffset.left - wrapperNode.getScrollLeft() - clientY = gutterClientRect.top + positionOffset.top - wrapperNode.getScrollTop() - return { - clientX: clientX, - clientY: clientY - } - } - - function lineAndLineNumberHaveClass (screenRow, klass) { - return lineHasClass(screenRow, klass) && lineNumberHasClass(screenRow, klass) - } - - function lineNumberHasClass (screenRow, klass) { - return component.lineNumberNodeForScreenRow(screenRow).classList.contains(klass) - } - - function lineNumberForBufferRowHasClass (bufferRow, klass) { - let screenRow - screenRow = editor.screenRowForBufferRow(bufferRow) - return component.lineNumberNodeForScreenRow(screenRow).classList.contains(klass) - } - - function lineHasClass (screenRow, klass) { - return component.lineNodeForScreenRow(screenRow).classList.contains(klass) - } - - function getLeafNodes (node) { - if (node.children.length > 0) { - return flatten(toArray(node.children).map(getLeafNodes)) - } else { - return [node] - } - } - - function conditionPromise (condition) { - let timeoutError = new Error("Timed out waiting on condition") - Error.captureStackTrace(timeoutError, conditionPromise) - - return new Promise(function (resolve, reject) { - let interval = window.setInterval.originalValue.apply(window, [function () { - if (condition()) { - window.clearInterval(interval) - window.clearTimeout(timeout) - resolve() - } - }, 100]) - let timeout = window.setTimeout.originalValue.apply(window, [function () { - window.clearInterval(interval) - reject(timeoutError) - }, 5000]) - }) - } - - function decorationsUpdatedPromise(editor) { - return new Promise(function (resolve) { - let disposable = editor.onDidUpdateDecorations(function () { - disposable.dispose() - resolve() - }) - }) - } }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index b9b08b8a8d6..8b372bf7018 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1,12 +1,14 @@ const etch = require('etch') const $ = etch.dom const TextEditorElement = require('./text-editor-element') +const resizeDetector = require('element-resize-detector')({strategy: 'scroll'}) -const ROWS_PER_TILE = 6 +const DEFAULT_ROWS_PER_TILE = 6 const NORMAL_WIDTH_CHARACTER = 'x' const DOUBLE_WIDTH_CHARACTER = '我' const HALF_WIDTH_CHARACTER = 'ハ' const KOREAN_CHARACTER = '세' +const NBSP_CHARACTER = '\u00a0' module.exports = class TextEditorComponent { @@ -18,12 +20,33 @@ class TextEditorComponent { this.virtualNode.domNode = this.element this.refs = {} etch.updateSync(this) + + resizeDetector.listenTo(this.element, this.didResize.bind(this)) } update (props) { + this.props = props + this.scheduleUpdate() + } + + scheduleUpdate () { + if (this.updatedSynchronously) { + this.updateSync() + } else { + etch.getScheduler().updateDocument(() => { + this.updateSync() + }) + } } updateSync () { + if (this.nextUpdatePromise) { + const resolveNextUpdatePromise = this.resolveNextUpdatePromise + this.nextUpdatePromise = null + this.resolveNextUpdatePromise = null + resolveNextUpdatePromise() + } + if (this.staleMeasurements.editorDimensions) this.measureEditorDimensions() etch.updateSync(this) } @@ -83,18 +106,21 @@ class TextEditorComponent { width: this.measurements.lineNumberGutterWidth + 'px' } - const firstTileStartRow = this.getTileStartRow(this.getFirstVisibleRow()) - const visibleTileCount = Math.floor((this.getLastVisibleRow() - this.getFirstVisibleRow()) / ROWS_PER_TILE) + 2 - const lastTileStartRow = firstTileStartRow + ((visibleTileCount - 1) * ROWS_PER_TILE) + const approximateLastScreenRow = this.getModel().getApproximateScreenLineCount() - 1 + const firstVisibleRow = this.getFirstVisibleRow() + const lastVisibleRow = this.getLastVisibleRow() + const firstTileStartRow = this.getTileStartRow(firstVisibleRow) + const visibleTileCount = Math.floor((lastVisibleRow - this.getFirstVisibleRow()) / this.getRowsPerTile()) + 2 + const lastTileStartRow = firstTileStartRow + ((visibleTileCount - 1) * this.getRowsPerTile()) children = new Array(visibleTileCount) let previousBufferRow = (firstTileStartRow > 0) ? this.getModel().bufferRowForScreenRow(firstTileStartRow - 1) : -1 - for (let tileStartRow = firstTileStartRow; tileStartRow <= lastTileStartRow; tileStartRow += ROWS_PER_TILE) { - const currentTileEndRow = tileStartRow + ROWS_PER_TILE + for (let tileStartRow = firstTileStartRow; tileStartRow <= lastTileStartRow; tileStartRow += this.getRowsPerTile()) { + const currentTileEndRow = tileStartRow + this.getRowsPerTile() const lineNumberNodes = [] - for (let row = tileStartRow; row < currentTileEndRow; row++) { + for (let row = tileStartRow; row < currentTileEndRow && row <= approximateLastScreenRow; row++) { const bufferRow = this.getModel().bufferRowForScreenRow(row) const foldable = this.getModel().isFoldableAtBufferRow(bufferRow) const softWrapped = (bufferRow === previousBufferRow) @@ -107,7 +133,7 @@ class TextEditorComponent { if (foldable) className += ' foldable' lineNumber = (bufferRow + 1).toString() } - lineNumber = '\u00a0'.repeat(maxLineNumberDigits - lineNumber.length) + lineNumber + lineNumber = NBSP_CHARACTER.repeat(maxLineNumberDigits - lineNumber.length) + lineNumber lineNumberNodes.push($.div({className}, lineNumber, @@ -117,8 +143,8 @@ class TextEditorComponent { previousBufferRow = bufferRow } - const tileIndex = (tileStartRow / ROWS_PER_TILE) % visibleTileCount - const tileHeight = ROWS_PER_TILE * this.measurements.lineHeight + const tileIndex = (tileStartRow / this.getRowsPerTile()) % visibleTileCount + const tileHeight = this.getRowsPerTile() * this.measurements.lineHeight children[tileIndex] = $.div({ style: { @@ -167,15 +193,15 @@ class TextEditorComponent { if (!this.measurements) return [] const firstTileStartRow = this.getTileStartRow(this.getFirstVisibleRow()) - const visibleTileCount = Math.floor((this.getLastVisibleRow() - this.getFirstVisibleRow()) / ROWS_PER_TILE) + 2 - const lastTileStartRow = firstTileStartRow + ((visibleTileCount - 1) * ROWS_PER_TILE) + const visibleTileCount = Math.floor((this.getLastVisibleRow() - this.getFirstVisibleRow()) / this.getRowsPerTile()) + 2 + const lastTileStartRow = firstTileStartRow + ((visibleTileCount - 1) * this.getRowsPerTile()) const displayLayer = this.getModel().displayLayer - const screenLines = displayLayer.getScreenLines(firstTileStartRow, lastTileStartRow + ROWS_PER_TILE) + const screenLines = displayLayer.getScreenLines(firstTileStartRow, lastTileStartRow + this.getRowsPerTile()) let tileNodes = new Array(visibleTileCount) - for (let tileStartRow = firstTileStartRow; tileStartRow <= lastTileStartRow; tileStartRow += ROWS_PER_TILE) { - const tileEndRow = tileStartRow + ROWS_PER_TILE + for (let tileStartRow = firstTileStartRow; tileStartRow <= lastTileStartRow; tileStartRow += this.getRowsPerTile()) { + const tileEndRow = tileStartRow + this.getRowsPerTile() const lineNodes = [] for (let row = tileStartRow; row < tileEndRow; row++) { const screenLine = screenLines[row - firstTileStartRow] @@ -183,12 +209,11 @@ class TextEditorComponent { lineNodes.push($(LineComponent, {key: screenLine.id, displayLayer, screenLine})) } - const tileHeight = ROWS_PER_TILE * this.measurements.lineHeight - const tileIndex = (tileStartRow / ROWS_PER_TILE) % visibleTileCount + const tileHeight = this.getRowsPerTile() * this.measurements.lineHeight + const tileIndex = (tileStartRow / this.getRowsPerTile()) % visibleTileCount tileNodes[tileIndex] = $.div({ key: tileIndex, - dataset: {key: tileIndex}, style: { contain: 'strict', position: 'absolute', @@ -232,8 +257,14 @@ class TextEditorComponent { this.updateSync() } + didResize () { + this.measureEditorDimensions() + this.scheduleUpdate() + } + performInitialMeasurements () { this.measurements = {} + this.staleMeasurements = {} this.measureEditorDimensions() this.measureScrollPosition() this.measureCharacterDimensions() @@ -292,8 +323,12 @@ class TextEditorComponent { return this.measurements ? this.measurements.scrollLeft : null } + getRowsPerTile () { + return this.props.rowsPerTile || DEFAULT_ROWS_PER_TILE + } + getTileStartRow (row) { - return row - (row % ROWS_PER_TILE) + return row - (row % this.getRowsPerTile()) } getFirstVisibleRow () { @@ -303,7 +338,10 @@ class TextEditorComponent { getLastVisibleRow () { const {scrollTop, scrollerHeight, lineHeight} = this.measurements - return this.getFirstVisibleRow() + Math.ceil(scrollerHeight / lineHeight) + return Math.min( + this.getModel().getApproximateScreenLineCount() - 1, + this.getFirstVisibleRow() + Math.ceil(scrollerHeight / lineHeight) + ) } topPixelPositionForRow (row) { @@ -313,6 +351,15 @@ class TextEditorComponent { getScrollHeight () { return this.getModel().getApproximateScreenLineCount() * this.measurements.lineHeight } + + getNextUpdatePromise () { + if (!this.nextUpdatePromise) { + this.nextUpdatePromise = new Promise((resolve) => { + this.resolveNextUpdatePromise = resolve + }) + } + return this.nextUpdatePromise + } } class LineComponent { diff --git a/src/text-editor-element.js b/src/text-editor-element.js index 9de974d67e8..58fcc33b5d6 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -46,9 +46,18 @@ class TextEditorElement extends HTMLElement { } getComponent () { - if (!this.component) this.component = new TextEditorComponent({element: this}) + if (!this.component) this.component = new TextEditorComponent({ + element: this, + updatedSynchronously: this.updatedSynchronously + }) return this.component } + + setUpdatedSynchronously (updatedSynchronously) { + this.updatedSynchronously = updatedSynchronously + if (this.component) this.component.updatedSynchronously = updatedSynchronously + return updatedSynchronously + } } module.exports = From ede5d5e5f45aa03079e4f1f71ec3493b96ab65b6 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 23 Feb 2017 15:44:19 -0700 Subject: [PATCH 052/403] Add coverage for gutter measurement and horizontal translation on scroll --- spec/text-editor-component-spec.js | 44 ++++++++++++++++++++++++------ src/text-editor-component.js | 2 +- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 2cebda10b1f..894a518ea10 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -11,21 +11,24 @@ const SAMPLE_TEXT = fs.readFileSync(path.join(__dirname, 'fixtures', 'sample.js' const NBSP_CHARACTER = '\u00a0' describe('TextEditorComponent', () => { - let editor - beforeEach(() => { jasmine.useRealClock() - editor = new TextEditor() - editor.setText(SAMPLE_TEXT) }) - it('renders lines and line numbers for the visible region', async () => { - const component = new TextEditorComponent({model: editor, rowsPerTile: 3}) + function buildComponent (params = {}) { + const editor = new TextEditor() + editor.setText(SAMPLE_TEXT) + const component = new TextEditorComponent({model: editor, rowsPerTile: params.rowsPerTile}) const {element} = component - - element.style.width = '800px' - element.style.height = '600px' + element.style.width = params.width ? params.width + 'px' : '800px' + element.style.height = params.height ? params.height + 'px' : '600px' jasmine.attachToDOM(element) + return {component, element, editor} + } + + it('renders lines and line numbers for the visible region', async () => { + const {component, element, editor} = buildComponent({rowsPerTile: 3}) + expect(element.querySelectorAll('.line-number').length).toBe(13) expect(element.querySelectorAll('.line').length).toBe(13) @@ -72,4 +75,27 @@ describe('TextEditorComponent', () => { editor.lineTextForScreenRow(8) ]) }) + + it('gives the line number gutter an explicit width and height so its layout can be strictly contained', () => { + const {component, element, editor} = buildComponent({rowsPerTile: 3}) + + const gutterElement = element.querySelector('.gutter.line-numbers') + expect(gutterElement.style.width).toBe(element.querySelector('.line-number').offsetWidth + 'px') + expect(gutterElement.style.height).toBe(editor.getScreenLineCount() * component.measurements.lineHeight + 'px') + expect(gutterElement.style.contain).toBe('strict') + + // Tile nodes also have explicit width and height assignment + expect(gutterElement.firstChild.style.width).toBe(element.querySelector('.line-number').offsetWidth + 'px') + expect(gutterElement.firstChild.style.height).toBe(3 * component.measurements.lineHeight + 'px') + expect(gutterElement.firstChild.style.contain).toBe('strict') + }) + + it('translates the gutter so it is always visible when scrolling to the right', async () => { + const {component, element, editor} = buildComponent({width: 100}) + + expect(component.refs.gutterContainer.style.transform).toBe('translateX(0px)') + component.refs.scroller.scrollLeft = 100 + await component.getNextUpdatePromise() + expect(component.refs.gutterContainer.style.transform).toBe('translateX(100px)') + }) }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 8b372bf7018..9afd41a2e15 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -74,7 +74,7 @@ class TextEditorComponent { } renderGutterContainer () { - const props = {className: 'gutter-container'} + const props = {ref: 'gutterContainer', className: 'gutter-container'} if (this.measurements) { props.style = { From 19d1d148eb063bd5b6894141a7bfdcadc6346bb9 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 23 Feb 2017 17:30:18 -0700 Subject: [PATCH 053/403] Measure the longest visible screen line on initial render --- spec/text-editor-component-spec.js | 15 ++++++- src/text-editor-component.js | 65 +++++++++++++++++++++++------- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 894a518ea10..5432065b1ad 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -4,6 +4,7 @@ import {it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise} from './a const TextEditorComponent = require('../src/text-editor-component') const TextEditor = require('../src/text-editor') +const TextBuffer = require('text-buffer') const fs = require('fs') const path = require('path') @@ -16,8 +17,8 @@ describe('TextEditorComponent', () => { }) function buildComponent (params = {}) { - const editor = new TextEditor() - editor.setText(SAMPLE_TEXT) + const buffer = new TextBuffer({text: SAMPLE_TEXT}) + const editor = new TextEditor({buffer}) const component = new TextEditorComponent({model: editor, rowsPerTile: params.rowsPerTile}) const {element} = component element.style.width = params.width ? params.width + 'px' : '800px' @@ -76,6 +77,16 @@ describe('TextEditorComponent', () => { ]) }) + it('bases the width of the lines div on the width of the longest initially-visible screen line', () => { + const {component, element, editor} = buildComponent({rowsPerTile: 2, height: 20}) + + expect(editor.getApproximateLongestScreenRow()).toBe(3) + const expectedWidth = element.querySelectorAll('.line')[3].offsetWidth + expect(element.querySelector('.lines').style.width).toBe(expectedWidth + 'px') + + // TODO: Confirm that we'll update this value as indexing proceeds + }) + it('gives the line number gutter an explicit width and height so its layout can be strictly contained', () => { const {component, element, editor} = buildComponent({rowsPerTile: 3}) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 9afd41a2e15..fa4773dc9b1 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -41,13 +41,23 @@ class TextEditorComponent { updateSync () { if (this.nextUpdatePromise) { - const resolveNextUpdatePromise = this.resolveNextUpdatePromise + this.resolveNextUpdatePromise() this.nextUpdatePromise = null this.resolveNextUpdatePromise = null - resolveNextUpdatePromise() } + if (this.staleMeasurements.editorDimensions) this.measureEditorDimensions() - etch.updateSync(this) + + const longestLine = this.getLongestScreenLine() + if (longestLine !== this.previousLongestLine) { + this.longestLineToMeasure = longestLine + etch.updateSync(this) + this.measureLongestLineWidth() + this.previousLongestLine = longestLine + etch.updateSync(this) + } else { + etch.updateSync(this) + } } render () { @@ -170,12 +180,14 @@ class TextEditorComponent { } renderLines () { - let style, children + let children + let style = { + contain: 'strict', + overflow: 'hidden' + } if (this.measurements) { - style = { - width: this.measurements.scrollWidth + 'px', - height: this.getScrollHeight() + 'px' - } + style.width = this.measurements.scrollWidth + 'px', + style.height = this.getScrollHeight() + 'px' children = this.renderLineTiles() } else { children = $.div({ref: 'characterMeasurementLine', className: 'line'}, @@ -206,7 +218,13 @@ class TextEditorComponent { for (let row = tileStartRow; row < tileEndRow; row++) { const screenLine = screenLines[row - firstTileStartRow] if (!screenLine) break - lineNodes.push($(LineComponent, {key: screenLine.id, displayLayer, screenLine})) + + const lineProps = {key: screenLine.id, displayLayer, screenLine} + if (screenLine === this.longestLineToMeasure) { + lineProps.ref = 'longestLineToMeasure' + this.longestLineToMeasure = null + } + lineNodes.push($(LineComponent, lineProps)) } const tileHeight = this.getRowsPerTile() * this.measurements.lineHeight @@ -226,6 +244,16 @@ class TextEditorComponent { }, lineNodes) } + if (this.longestLineToMeasure) { + tileNodes.push($(LineComponent, { + ref: 'longestLineToMeasure', + key: this.longestLineToMeasure.id, + displayLayer, + screenLine: this.longestLineToMeasure + })) + this.longestLineToMeasure = null + } + return tileNodes } @@ -245,7 +273,7 @@ class TextEditorComponent { didShow () { this.getModel().setVisible(true) if (!this.measurements) this.performInitialMeasurements() - etch.updateSync(this) + this.updateSync() } didHide () { @@ -268,7 +296,6 @@ class TextEditorComponent { this.measureEditorDimensions() this.measureScrollPosition() this.measureCharacterDimensions() - this.measureLongestLineWidth() this.measureGutterDimensions() } @@ -290,9 +317,7 @@ class TextEditorComponent { } measureLongestLineWidth () { - const displayLayer = this.getModel().displayLayer - const rightmostPosition = displayLayer.getRightmostScreenPosition() - this.measurements.scrollWidth = rightmostPosition.column * this.measurements.baseCharacterWidth + this.measurements.scrollWidth = this.refs.longestLineToMeasure.element.firstChild.offsetWidth } measureGutterDimensions () { @@ -352,6 +377,15 @@ class TextEditorComponent { return this.getModel().getApproximateScreenLineCount() * this.measurements.lineHeight } + getLongestScreenLine () { + const model = this.getModel() + // Ensure the spatial index is populated with rows that are currently + // visible so we *at least* get the longest row in the visible range. + const renderedEndRow = this.getTileStartRow(this.getLastVisibleRow()) + this.getRowsPerTile() + model.displayLayer.populateSpatialIndexIfNeeded(Infinity, renderedEndRow) + return model.screenLineForScreenRow(model.getApproximateLongestScreenRow()) + } + getNextUpdatePromise () { if (!this.nextUpdatePromise) { this.nextUpdatePromise = new Promise((resolve) => { @@ -370,7 +404,8 @@ class LineComponent { const textNodes = [] let startIndex = 0 - let openScopeNode = this.element + let openScopeNode = document.createElement('span') + this.element.appendChild(openScopeNode) for (let i = 0; i < tagCodes.length; i++) { const tagCode = tagCodes[i] if (tagCode !== 0) { From 583c2c537da3037a5e5dea2a091aeef86f07f040 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 23 Feb 2017 21:30:35 -0700 Subject: [PATCH 054/403] Iron out scheduling issues * Ensure multiple calls to scheduleUpdate only result in a single call to updateSync in the future. * Explicit calls to update sync after scheduling an update fulfill the scheduled update. * Track whether we think the editor is visible or not to avoid redundant didShow calls. * Ensure we only update on resize events if the editor actually changed size. --- spec/text-editor-component-spec.js | 6 ++++- src/text-editor-component.js | 38 ++++++++++++++++++++++-------- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 5432065b1ad..e6bf8877b90 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -19,7 +19,11 @@ describe('TextEditorComponent', () => { function buildComponent (params = {}) { const buffer = new TextBuffer({text: SAMPLE_TEXT}) const editor = new TextEditor({buffer}) - const component = new TextEditorComponent({model: editor, rowsPerTile: params.rowsPerTile}) + const component = new TextEditorComponent({ + model: editor, + rowsPerTile: params.rowsPerTile, + updatedSynchronously: false + }) const {element} = component element.style.width = params.width ? params.width + 'px' : '800px' element.style.height = params.height ? params.height + 'px' : '600px' diff --git a/src/text-editor-component.js b/src/text-editor-component.js index fa4773dc9b1..89c09277d73 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -19,9 +19,12 @@ class TextEditorComponent { this.virtualNode = $('atom-text-editor') this.virtualNode.domNode = this.element this.refs = {} - etch.updateSync(this) + this.updateScheduled = false + this.visible = false resizeDetector.listenTo(this.element, this.didResize.bind(this)) + + etch.updateSync(this) } update (props) { @@ -32,14 +35,16 @@ class TextEditorComponent { scheduleUpdate () { if (this.updatedSynchronously) { this.updateSync() - } else { + } else if (!this.updateScheduled) { + this.updateScheduled = true etch.getScheduler().updateDocument(() => { - this.updateSync() + if (this.updateScheduled) this.updateSync() }) } } updateSync () { + this.updateScheduled = false if (this.nextUpdatePromise) { this.resolveNextUpdatePromise() this.nextUpdatePromise = null @@ -271,13 +276,19 @@ class TextEditorComponent { } didShow () { - this.getModel().setVisible(true) - if (!this.measurements) this.performInitialMeasurements() - this.updateSync() + if (!this.visible) { + this.visible = true + this.getModel().setVisible(true) + if (!this.measurements) this.performInitialMeasurements() + this.updateSync() + } } didHide () { - this.getModel().setVisible(false) + if (this.visible) { + this.visible = false + this.getModel().setVisible(false) + } } didScroll () { @@ -286,8 +297,9 @@ class TextEditorComponent { } didResize () { - this.measureEditorDimensions() - this.scheduleUpdate() + if (this.measureEditorDimensions()) { + this.scheduleUpdate() + } } performInitialMeasurements () { @@ -300,7 +312,13 @@ class TextEditorComponent { } measureEditorDimensions () { - this.measurements.scrollerHeight = this.refs.scroller.offsetHeight + const scrollerHeight = this.refs.scroller.offsetHeight + if (scrollerHeight !== this.measurements.scrollerHeight) { + this.measurements.scrollerHeight = this.refs.scroller.offsetHeight + return true + } else { + return false + } } measureScrollPosition () { From 43386b048346911cb2d16cec7aa0b05453c717af Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 24 Feb 2017 11:00:38 -0700 Subject: [PATCH 055/403] Always update twice assuming we may need to measure This prepares the ground for measuring absoltue cursor positions. --- src/text-editor-component.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 89c09277d73..2b8b19db70e 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -54,15 +54,16 @@ class TextEditorComponent { if (this.staleMeasurements.editorDimensions) this.measureEditorDimensions() const longestLine = this.getLongestScreenLine() + let measureLongestLine = false if (longestLine !== this.previousLongestLine) { this.longestLineToMeasure = longestLine - etch.updateSync(this) - this.measureLongestLineWidth() this.previousLongestLine = longestLine - etch.updateSync(this) - } else { - etch.updateSync(this) + measureLongestLine = true } + + etch.updateSync(this) + if (measureLongestLine) this.measureLongestLineWidth() + etch.updateSync(this) } render () { From c8166c1bb360565d70d8db2a3823d0f3dd772f86 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sat, 25 Feb 2017 12:23:54 -0700 Subject: [PATCH 056/403] Render cursors after measuring horizontal positions --- src/text-editor-component.js | 200 +++++++++++++++++++++++++++++++---- 1 file changed, 180 insertions(+), 20 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 2b8b19db70e..f40f6ead643 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -21,7 +21,14 @@ class TextEditorComponent { this.refs = {} this.updateScheduled = false + this.measurements = null this.visible = false + this.horizontalPositionsToMeasure = new Map() // Keys are rows with positions we want to measure, values are arrays of columns to measure + this.horizontalPixelPositionsByScreenLine = new WeakMap() // Values are maps from column to horiontal pixel positions + this.lineNodesByScreenLine = new WeakMap() + this.textNodesByScreenLine = new WeakMap() + this.cursorsToRender = [] + resizeDetector.listenTo(this.element, this.didResize.bind(this)) etch.updateSync(this) @@ -61,8 +68,14 @@ class TextEditorComponent { measureLongestLine = true } + this.horizontalPositionsToMeasure.clear() + this.populateCursorPositionsToMeasure() + etch.updateSync(this) - if (measureLongestLine) this.measureLongestLineWidth() + if (measureLongestLine) this.measureLongestLineWidth(longestLine) + this.measureHorizontalPositions() + this.updateCursorsToRender() + etch.updateSync(this) } @@ -83,7 +96,7 @@ class TextEditorComponent { } }, this.renderGutterContainer(), - this.renderLines() + this.renderContent() ) ) ) @@ -185,16 +198,21 @@ class TextEditorComponent { return $.div(props, children) } - renderLines () { + renderContent () { let children let style = { contain: 'strict', overflow: 'hidden' } if (this.measurements) { - style.width = this.measurements.scrollWidth + 'px', - style.height = this.getScrollHeight() + 'px' - children = this.renderLineTiles() + const width = this.measurements.scrollWidth + 'px' + const height = this.getScrollHeight() + 'px' + style.width = width + style.height = height + children = [ + this.renderCursors(width, height), + this.renderLineTiles(width, height) + ] } else { children = $.div({ref: 'characterMeasurementLine', className: 'line'}, $.span({ref: 'normalWidthCharacterSpan'}, NORMAL_WIDTH_CHARACTER), @@ -207,9 +225,11 @@ class TextEditorComponent { return $.div({ref: 'lines', className: 'lines', style}, children) } - renderLineTiles () { + renderLineTiles (width, height) { if (!this.measurements) return [] + const {lineNodesByScreenLine, textNodesByScreenLine} = this + const firstTileStartRow = this.getTileStartRow(this.getFirstVisibleRow()) const visibleTileCount = Math.floor((this.getLastVisibleRow() - this.getFirstVisibleRow()) / this.getRowsPerTile()) + 2 const lastTileStartRow = firstTileStartRow + ((visibleTileCount - 1) * this.getRowsPerTile()) @@ -224,13 +244,16 @@ class TextEditorComponent { for (let row = tileStartRow; row < tileEndRow; row++) { const screenLine = screenLines[row - firstTileStartRow] if (!screenLine) break - - const lineProps = {key: screenLine.id, displayLayer, screenLine} + lineNodes.push($(LineComponent, { + key: screenLine.id, + screenLine, + displayLayer, + lineNodesByScreenLine, + textNodesByScreenLine + })) if (screenLine === this.longestLineToMeasure) { - lineProps.ref = 'longestLineToMeasure' this.longestLineToMeasure = null } - lineNodes.push($(LineComponent, lineProps)) } const tileHeight = this.getRowsPerTile() * this.measurements.lineHeight @@ -252,15 +275,72 @@ class TextEditorComponent { if (this.longestLineToMeasure) { tileNodes.push($(LineComponent, { - ref: 'longestLineToMeasure', key: this.longestLineToMeasure.id, + screenLine: this.longestLineToMeasure, displayLayer, - screenLine: this.longestLineToMeasure + lineNodesByScreenLine, + textNodesByScreenLine })) this.longestLineToMeasure = null } - return tileNodes + return $.div({ + key: 'lineTiles', + style: { + position: 'absolute', + contain: 'strict', + width, height + } + }, tileNodes) + } + + renderCursors (width, height) { + return $.div({ + key: 'cursors', + className: 'cursors', + style: { + position: 'absolute', + contain: 'strict', + width, height + } + }, + this.cursorsToRender.map(style => $.div({className: 'cursor', style})) + ) + } + + populateCursorPositionsToMeasure () { + const model = this.getModel() + for (let i = 0; i < model.cursors.length; i++) { + const cursor = model.cursors[i] + const position = cursor.getScreenPosition() + let columns = this.horizontalPositionsToMeasure.get(position.row) + if (columns == null) { + columns = [] + this.horizontalPositionsToMeasure.set(position.row, columns) + } + columns.push(position.column) + columns.push(position.column + 1) + } + + this.horizontalPositionsToMeasure.forEach((value) => value.sort((a, b) => a - b)) + } + + updateCursorsToRender () { + const model = this.getModel() + const height = this.measurements.lineHeight + 'px' + this.cursorsToRender.length = 0 + for (let i = 0; i < model.cursors.length; i++) { + const cursor = model.cursors[i] + const position = cursor.getScreenPosition() + const top = this.pixelTopForScreenRow(position.row) + const left = this.pixelLeftForScreenPosition(position) + const right = this.pixelLeftForScreenRowAndColumn(position.row, position.column + 1) + this.cursorsToRender.push({ + height, + width: (right - left) + 'px', + transform: `translate(${top}px, ${left}px)` + }) + } } didAttach () { @@ -335,14 +415,87 @@ class TextEditorComponent { this.measurements.koreanCharacterWidth = this.refs.koreanCharacterSpan.getBoundingClientRect().widt } - measureLongestLineWidth () { - this.measurements.scrollWidth = this.refs.longestLineToMeasure.element.firstChild.offsetWidth + measureLongestLineWidth (screenLine) { + this.measurements.scrollWidth = this.lineNodesByScreenLine.get(screenLine).firstChild.offsetWidth } measureGutterDimensions () { this.measurements.lineNumberGutterWidth = this.refs.lineNumberGutter.offsetWidth } + measureHorizontalPositions () { + this.horizontalPositionsToMeasure.forEach((columnsToMeasure, row) => { + const screenLine = this.getModel().displayLayer.getScreenLine(row) + + const lineNode = this.lineNodesByScreenLine.get(screenLine) + const textNodes = this.textNodesByScreenLine.get(screenLine) + let positionsForLine = this.horizontalPixelPositionsByScreenLine.get(screenLine) + if (positionsForLine == null) { + positionsForLine = new Map() + this.horizontalPixelPositionsByScreenLine.set(screenLine, positionsForLine) + } + + this.measureHorizontalPositionsOnLine(lineNode, textNodes, columnsToMeasure, positionsForLine) + }) + } + + measureHorizontalPositionsOnLine (lineNode, textNodes, columnsToMeasure, positions) { + let lineNodeClientLeft = -1 + let textNodeStartColumn = 0 + let textNodesIndex = 0 + + columnLoop: + for (let columnsIndex = 0; columnsIndex < columnsToMeasure.length; columnsIndex++) { + while (textNodesIndex < textNodes.length) { + const nextColumnToMeasure = columnsToMeasure[columnsIndex] + if (nextColumnToMeasure === 0) { + positions.set(0, 0) + continue columnLoop + } + if (positions.has(nextColumnToMeasure)) continue columnLoop + const textNode = textNodes[textNodesIndex] + const textNodeEndColumn = textNodeStartColumn + textNode.textContent.length + + if (nextColumnToMeasure <= textNodeEndColumn) { + let clientPixelPosition + if (nextColumnToMeasure === textNodeStartColumn) { + const range = getRangeForMeasurement() + range.selectNode(textNode) + clientPixelPosition = range.getBoundingClientRect().left + } else if (nextColumnToMeasure === textNodeEndColumn) { + const range = getRangeForMeasurement() + range.selectNode(textNode) + clientPixelPosition = range.getBoundingClientRect().right + } else { + const range = getRangeForMeasurement() + range.setStart(textNode, 0) + range.setEnd(textNode, nextColumnToMeasure - textNodeStartColumn) + clientPixelPosition = range.getBoundingClientRect().right + } + if (lineNodeClientLeft === -1) lineNodeClientLeft = lineNode.getBoundingClientRect().left + positions.set(nextColumnToMeasure, clientPixelPosition - lineNodeClientLeft) + continue columnLoop + } else { + textNodesIndex++ + textNodeStartColumn = textNodeEndColumn + } + } + } + } + + pixelTopForScreenRow (row) { + return row * this.measurements.lineHeight + } + + pixelLeftForScreenPosition ({row, column}) { + return this.pixelLeftForScreenRowAndColumn(row, column) + } + + pixelLeftForScreenRowAndColumn (row, column) { + const screenLine = this.getModel().displayLayer.getScreenLine(row) + return this.horizontalPixelPositionsByScreenLine.get(screenLine).get(column) + } + getModel () { if (!this.props.model) { const TextEditor = require('./text-editor') @@ -416,12 +569,15 @@ class TextEditorComponent { } class LineComponent { - constructor ({displayLayer, screenLine}) { - const {lineText, tagCodes} = screenLine + constructor ({displayLayer, screenLine, lineNodesByScreenLine, textNodesByScreenLine}) { this.element = document.createElement('div') this.element.classList.add('line') + lineNodesByScreenLine.set(screenLine, this.element) const textNodes = [] + textNodesByScreenLine.set(screenLine, textNodes) + + const {lineText, tagCodes} = screenLine let startIndex = 0 let openScopeNode = document.createElement('span') this.element.appendChild(openScopeNode) @@ -459,8 +615,6 @@ class LineComponent { this.element.appendChild(textNode) textNodes.push(textNode) } - - // this.textNodesByLineId[id] = textNodes } update () {} @@ -475,3 +629,9 @@ function classNameForScopeName (scopeName) { } return classString } + +let rangeForMeasurement +function getRangeForMeasurement () { + if (!rangeForMeasurement) rangeForMeasurement = document.createRange() + return rangeForMeasurement +} From d780b152482128e568505da74be332d3190d0ad0 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sat, 25 Feb 2017 16:53:21 -0700 Subject: [PATCH 057/403] Add cursor rendering tests --- spec/text-editor-component-spec.js | 73 +++++++++++++++ src/text-editor-component.js | 144 ++++++++++++++++++++--------- src/text-editor.coffee | 2 +- 3 files changed, 172 insertions(+), 47 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index e6bf8877b90..d8528a46067 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -113,4 +113,77 @@ describe('TextEditorComponent', () => { await component.getNextUpdatePromise() expect(component.refs.gutterContainer.style.transform).toBe('translateX(100px)') }) + + it('renders cursors within the visible row range', async () => { + const {component, element, editor} = buildComponent({height: 40, rowsPerTile: 2}) + component.refs.scroller.scrollTop = 100 + await component.getNextUpdatePromise() + + expect(component.getRenderedStartRow()).toBe(4) + expect(component.getRenderedEndRow()).toBe(10) + + editor.setCursorScreenPosition([0, 0]) // out of view + editor.addCursorAtScreenPosition([2, 2]) // out of view + editor.addCursorAtScreenPosition([4, 0]) // line start + editor.addCursorAtScreenPosition([4, 4]) // at token boundary + editor.addCursorAtScreenPosition([4, 6]) // within token + editor.addCursorAtScreenPosition([5, Infinity]) // line end + editor.addCursorAtScreenPosition([10, 2]) // out of view + await component.getNextUpdatePromise() + + let cursorNodes = Array.from(element.querySelectorAll('.cursor')) + expect(cursorNodes.length).toBe(4) + verifyCursorPosition(component, cursorNodes[0], 4, 0) + verifyCursorPosition(component, cursorNodes[1], 4, 4) + verifyCursorPosition(component, cursorNodes[2], 4, 6) + verifyCursorPosition(component, cursorNodes[3], 5, 30) + + editor.setCursorScreenPosition([8, 11]) + await component.getNextUpdatePromise() + + cursorNodes = Array.from(element.querySelectorAll('.cursor')) + expect(cursorNodes.length).toBe(1) + verifyCursorPosition(component, cursorNodes[0], 8, 11) + + editor.setCursorScreenPosition([0, 0]) + await component.getNextUpdatePromise() + + cursorNodes = Array.from(element.querySelectorAll('.cursor')) + expect(cursorNodes.length).toBe(0) + }) }) + +function verifyCursorPosition (component, cursorNode, row, column) { + const rect = cursorNode.getBoundingClientRect() + expect(Math.round(rect.top)).toBe(clientTopForLine(component, row)) + expect(Math.round(rect.left)).toBe(clientLeftForCharacter(component, row, column)) +} + +function clientTopForLine (component, row) { + return lineNodeForScreenRow(component, row).getBoundingClientRect().top +} + +function clientLeftForCharacter (component, row, column) { + const textNodes = textNodesForScreenRow(component, row) + let textNodeStartColumn = 0 + for (const textNode of textNodes) { + const textNodeEndColumn = textNodeStartColumn + textNode.textContent.length + if (column <= textNodeEndColumn) { + const range = document.createRange() + range.setStart(textNode, column - textNodeStartColumn) + range.setEnd(textNode, column - textNodeStartColumn) + return range.getBoundingClientRect().left + } + textNodeStartColumn = textNodeEndColumn + } +} + +function lineNodeForScreenRow (component, row) { + const screenLine = component.getModel().screenLineForScreenRow(row) + return component.lineNodesByScreenLine.get(screenLine) +} + +function textNodesForScreenRow (component, row) { + const screenLine = component.getModel().screenLineForScreenRow(row) + return component.textNodesByScreenLine.get(screenLine) +} diff --git a/src/text-editor-component.js b/src/text-editor-component.js index f40f6ead643..d3946dec69e 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1,4 +1,5 @@ const etch = require('etch') +const {CompositeDisposable} = require('event-kit') const $ = etch.dom const TextEditorElement = require('./text-editor-element') const resizeDetector = require('element-resize-detector')({strategy: 'scroll'}) @@ -20,6 +21,7 @@ class TextEditorComponent { this.virtualNode.domNode = this.element this.refs = {} + this.disposables = new CompositeDisposable() this.updateScheduled = false this.measurements = null this.visible = false @@ -29,6 +31,7 @@ class TextEditorComponent { this.textNodesByScreenLine = new WeakMap() this.cursorsToRender = [] + if (this.props.model) this.observeModel() resizeDetector.listenTo(this.element, this.didResize.bind(this)) etch.updateSync(this) @@ -69,12 +72,11 @@ class TextEditorComponent { } this.horizontalPositionsToMeasure.clear() - this.populateCursorPositionsToMeasure() - etch.updateSync(this) if (measureLongestLine) this.measureLongestLineWidth(longestLine) + this.queryCursorsToRender() this.measureHorizontalPositions() - this.updateCursorsToRender() + this.positionCursorsToRender() etch.updateSync(this) } @@ -138,9 +140,9 @@ class TextEditorComponent { const approximateLastScreenRow = this.getModel().getApproximateScreenLineCount() - 1 const firstVisibleRow = this.getFirstVisibleRow() const lastVisibleRow = this.getLastVisibleRow() - const firstTileStartRow = this.getTileStartRow(firstVisibleRow) - const visibleTileCount = Math.floor((lastVisibleRow - this.getFirstVisibleRow()) / this.getRowsPerTile()) + 2 - const lastTileStartRow = firstTileStartRow + ((visibleTileCount - 1) * this.getRowsPerTile()) + const firstTileStartRow = this.getFirstTileStartRow() + const visibleTileCount = this.getVisibleTileCount() + const lastTileStartRow = this.getLastTileStartRow() children = new Array(visibleTileCount) @@ -230,9 +232,9 @@ class TextEditorComponent { const {lineNodesByScreenLine, textNodesByScreenLine} = this - const firstTileStartRow = this.getTileStartRow(this.getFirstVisibleRow()) - const visibleTileCount = Math.floor((this.getLastVisibleRow() - this.getFirstVisibleRow()) / this.getRowsPerTile()) + 2 - const lastTileStartRow = firstTileStartRow + ((visibleTileCount - 1) * this.getRowsPerTile()) + const firstTileStartRow = this.getFirstTileStartRow() + const visibleTileCount = this.getVisibleTileCount() + const lastTileStartRow = this.getLastTileStartRow() const displayLayer = this.getModel().displayLayer const screenLines = displayLayer.getScreenLines(firstTileStartRow, lastTileStartRow + this.getRowsPerTile()) @@ -295,6 +297,8 @@ class TextEditorComponent { } renderCursors (width, height) { + const cursorHeight = this.measurements.lineHeight + 'px' + return $.div({ key: 'cursors', className: 'cursors', @@ -304,42 +308,61 @@ class TextEditorComponent { width, height } }, - this.cursorsToRender.map(style => $.div({className: 'cursor', style})) + this.cursorsToRender.map(({pixelLeft, pixelTop, pixelWidth}) => + $.div({ + className: 'cursor', + style: { + height: cursorHeight, + width: pixelWidth + 'px', + transform: `translate(${pixelLeft}px, ${pixelTop}px)` + } + }) + ) ) } - populateCursorPositionsToMeasure () { + queryCursorsToRender () { const model = this.getModel() - for (let i = 0; i < model.cursors.length; i++) { - const cursor = model.cursors[i] - const position = cursor.getScreenPosition() - let columns = this.horizontalPositionsToMeasure.get(position.row) - if (columns == null) { - columns = [] - this.horizontalPositionsToMeasure.set(position.row, columns) + const cursorMarkers = model.selectionsMarkerLayer.findMarkers({ + intersectsScreenRowRange: [ + this.getRenderedStartRow(), + this.getRenderedEndRow() - 1, + ] + }) + + this.cursorsToRender.length = cursorMarkers.length + for (let i = 0; i < cursorMarkers.length; i++) { + const screenPosition = cursorMarkers[i].getHeadScreenPosition() + const {row, column} = screenPosition + this.requestHorizontalMeasurement(row, column) + let columnWidth = 0 + if (model.lineLengthForScreenRow(row) > column) { + columnWidth = 1 + this.requestHorizontalMeasurement(row, column + 1) + } + this.cursorsToRender[i] = { + screenPosition, columnWidth, + pixelTop: 0, pixelLeft: 0, pixelWidth: 0 } - columns.push(position.column) - columns.push(position.column + 1) } - - this.horizontalPositionsToMeasure.forEach((value) => value.sort((a, b) => a - b)) } - updateCursorsToRender () { - const model = this.getModel() + positionCursorsToRender () { const height = this.measurements.lineHeight + 'px' - this.cursorsToRender.length = 0 - for (let i = 0; i < model.cursors.length; i++) { - const cursor = model.cursors[i] - const position = cursor.getScreenPosition() - const top = this.pixelTopForScreenRow(position.row) - const left = this.pixelLeftForScreenPosition(position) - const right = this.pixelLeftForScreenRowAndColumn(position.row, position.column + 1) - this.cursorsToRender.push({ - height, - width: (right - left) + 'px', - transform: `translate(${top}px, ${left}px)` - }) + for (let i = 0; i < this.cursorsToRender.length; i++) { + const cursorToRender = this.cursorsToRender[i] + const {row, column} = cursorToRender.screenPosition + + const pixelTop = this.pixelTopForScreenRow(row) + const pixelLeft = this.pixelLeftForScreenRowAndColumn(row, column) + const pixelRight = (cursorToRender.columnWidth === 0) + ? pixelLeft + : this.pixelLeftForScreenRowAndColumn(row, column + 1) + const pixelWidth = pixelRight - pixelLeft + + cursorToRender.pixelTop = pixelTop + cursorToRender.pixelLeft = pixelLeft + cursorToRender.pixelWidth = pixelWidth } } @@ -423,10 +446,20 @@ class TextEditorComponent { this.measurements.lineNumberGutterWidth = this.refs.lineNumberGutter.offsetWidth } + requestHorizontalMeasurement (row, column) { + let columns = this.horizontalPositionsToMeasure.get(row) + if (columns == null) { + columns = [] + this.horizontalPositionsToMeasure.set(row, columns) + } + columns.push(column) + } + measureHorizontalPositions () { this.horizontalPositionsToMeasure.forEach((columnsToMeasure, row) => { - const screenLine = this.getModel().displayLayer.getScreenLine(row) + columnsToMeasure.sort((a, b) => a - b) + const screenLine = this.getModel().displayLayer.getScreenLine(row) const lineNode = this.lineNodesByScreenLine.get(screenLine) const textNodes = this.textNodesByScreenLine.get(screenLine) let positionsForLine = this.horizontalPixelPositionsByScreenLine.get(screenLine) @@ -460,12 +493,9 @@ class TextEditorComponent { let clientPixelPosition if (nextColumnToMeasure === textNodeStartColumn) { const range = getRangeForMeasurement() - range.selectNode(textNode) + range.setStart(textNode, 0) + range.setEnd(textNode, 1) clientPixelPosition = range.getBoundingClientRect().left - } else if (nextColumnToMeasure === textNodeEndColumn) { - const range = getRangeForMeasurement() - range.selectNode(textNode) - clientPixelPosition = range.getBoundingClientRect().right } else { const range = getRangeForMeasurement() range.setStart(textNode, 0) @@ -487,10 +517,6 @@ class TextEditorComponent { return row * this.measurements.lineHeight } - pixelLeftForScreenPosition ({row, column}) { - return this.pixelLeftForScreenRowAndColumn(row, column) - } - pixelLeftForScreenRowAndColumn (row, column) { const screenLine = this.getModel().displayLayer.getScreenLine(row) return this.horizontalPixelPositionsByScreenLine.get(screenLine).get(column) @@ -500,10 +526,16 @@ class TextEditorComponent { if (!this.props.model) { const TextEditor = require('./text-editor') this.props.model = new TextEditor() + this.observeModel() } return this.props.model } + observeModel () { + const {model} = this.props + this.disposables.add(model.selectionsMarkerLayer.onDidUpdate(this.scheduleUpdate.bind(this))) + } + isVisible () { return this.element.offsetWidth > 0 || this.element.offsetHeight > 0 } @@ -528,6 +560,26 @@ class TextEditorComponent { return row - (row % this.getRowsPerTile()) } + getVisibleTileCount () { + return Math.floor((this.getLastVisibleRow() - this.getFirstVisibleRow()) / this.getRowsPerTile()) + 2 + } + + getFirstTileStartRow () { + return this.getTileStartRow(this.getFirstVisibleRow()) + } + + getLastTileStartRow () { + return this.getFirstTileStartRow() + ((this.getVisibleTileCount() - 1) * this.getRowsPerTile()) + } + + getRenderedStartRow () { + return this.getFirstTileStartRow() + } + + getRenderedEndRow () { + return this.getFirstTileStartRow() + this.getVisibleTileCount() * this.getRowsPerTile() + } + getFirstVisibleRow () { const {scrollTop, lineHeight} = this.measurements return Math.floor(scrollTop / lineHeight) diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 812c2274988..b7e07822385 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -986,7 +986,7 @@ class TextEditor extends Model tokens screenLineForScreenRow: (screenRow) -> - @displayLayer.getScreenLines(screenRow, screenRow + 1)[0] + @displayLayer.getScreenLine(screenRow) bufferRowForScreenRow: (screenRow) -> @displayLayer.translateScreenPosition(Point(screenRow, 0)).row From be7f4a5ffd886e87d970c148e787ada4d8d7d2b7 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sun, 26 Feb 2017 10:49:35 -0700 Subject: [PATCH 058/403] Add workaround in test, but we need to make MarkerLayer updates sync --- spec/text-editor-component-spec.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index d8528a46067..b2a2ba8c3ee 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -34,6 +34,12 @@ describe('TextEditorComponent', () => { it('renders lines and line numbers for the visible region', async () => { const {component, element, editor} = buildComponent({rowsPerTile: 3}) + // TODO: An extra update is caused by marker layer events being asynchronous, + // so the cursor getting added triggers an update even though we created + // the component after this occurred. We should make marker layer events + // synchronous and batched on the transaction. + await component.getNextUpdatePromise() + expect(element.querySelectorAll('.line-number').length).toBe(13) expect(element.querySelectorAll('.line').length).toBe(13) From b362f746f8fb21d9850f02ca82cc50e90e126d38 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 27 Feb 2017 15:14:39 -0700 Subject: [PATCH 059/403] Fix spurious selections marker layer update to avoid extra render --- spec/text-editor-component-spec.js | 6 ------ src/text-editor.coffee | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index b2a2ba8c3ee..d8528a46067 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -34,12 +34,6 @@ describe('TextEditorComponent', () => { it('renders lines and line numbers for the visible region', async () => { const {component, element, editor} = buildComponent({rowsPerTile: 3}) - // TODO: An extra update is caused by marker layer events being asynchronous, - // so the cursor getting added triggers an update even though we created - // the component after this occurred. We should make marker layer events - // synchronous and batched on the transaction. - await component.getNextUpdatePromise() - expect(element.querySelectorAll('.line-number').length).toBe(13) expect(element.querySelectorAll('.line').length).toBe(13) diff --git a/src/text-editor.coffee b/src/text-editor.coffee index b7e07822385..f66159a0119 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -210,7 +210,7 @@ class TextEditor extends Model if @cursors.length is 0 and not suppressCursorCreation initialLine = Math.max(parseInt(initialLine) or 0, 0) initialColumn = Math.max(parseInt(initialColumn) or 0, 0) - @addCursorAtBufferPosition([initialLine, initialColumn]) + @addCursorAtBufferPosition([initialLine, initialColumn], {suppressLayerUpdateEvent: true}) @languageMode = new LanguageMode(this) @@ -2140,7 +2140,7 @@ class TextEditor extends Model # # Returns a {Cursor}. addCursorAtBufferPosition: (bufferPosition, options) -> - @selectionsMarkerLayer.markBufferPosition(bufferPosition, {invalidate: 'never'}) + @selectionsMarkerLayer.markBufferPosition(bufferPosition, Object.assign({invalidate: 'never'}, options)) @getLastSelection().cursor.autoscroll() unless options?.autoscroll is false @getLastSelection().cursor From 9487c1cd00971ed0f7c83bbfdcb5262ff01fc16f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 27 Feb 2017 15:21:53 -0700 Subject: [PATCH 060/403] Move lines class --- src/text-editor-component.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index d3946dec69e..de5584ae98c 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -224,7 +224,7 @@ class TextEditorComponent { ) } - return $.div({ref: 'lines', className: 'lines', style}, children) + return $.div({style}, children) } renderLineTiles (width, height) { @@ -288,6 +288,7 @@ class TextEditorComponent { return $.div({ key: 'lineTiles', + className: 'lines', style: { position: 'absolute', contain: 'strict', From c52d66377fbd90087fbe3d970e82ecd9c020fcbb Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 27 Feb 2017 16:34:41 -0700 Subject: [PATCH 061/403] Render hidden input and handle focus and blur --- spec/text-editor-component-spec.js | 49 +++++++- src/text-editor-component.js | 177 ++++++++++++++++++++++------- static/text-editor-light.less | 11 -- 3 files changed, 186 insertions(+), 51 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index d8528a46067..99977929515 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -151,6 +151,51 @@ describe('TextEditorComponent', () => { cursorNodes = Array.from(element.querySelectorAll('.cursor')) expect(cursorNodes.length).toBe(0) }) + + it('places the hidden input element at the location of the last cursor if it is visible', async () => { + const {component, element, editor} = buildComponent({height: 60, width: 120, rowsPerTile: 2}) + const {hiddenInput} = component.refs + component.refs.scroller.scrollTop = 100 + component.refs.scroller.scrollLeft = 40 + await component.getNextUpdatePromise() + + expect(component.getRenderedStartRow()).toBe(4) + expect(component.getRenderedEndRow()).toBe(12) + + // When out of view, the hidden input is positioned at 0, 0 + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + console.log(hiddenInput.offsetParent); + console.log(hiddenInput.offsetTop); + expect(hiddenInput.offsetTop).toBe(0) + expect(hiddenInput.offsetLeft).toBe(0) + + // Otherwise it is positioned at the last cursor position + editor.addCursorAtScreenPosition([7, 4]) + await component.getNextUpdatePromise() + expect(hiddenInput.getBoundingClientRect().top).toBe(clientTopForLine(component, 7)) + expect(Math.round(hiddenInput.getBoundingClientRect().left)).toBe(clientLeftForCharacter(component, 7, 4)) + }) + + it('focuses the hidden input elemnent and adds the is-focused class when focused', async () => { + const {component, element, editor} = buildComponent() + const {hiddenInput} = component.refs + + expect(document.activeElement).not.toBe(hiddenInput) + element.focus() + expect(document.activeElement).toBe(hiddenInput) + await component.getNextUpdatePromise() + expect(element.classList.contains('is-focused')).toBe(true) + + element.focus() // focusing back to the element does not blur + expect(document.activeElement).toBe(hiddenInput) + await component.getNextUpdatePromise() + expect(element.classList.contains('is-focused')).toBe(true) + + document.body.focus() + expect(document.activeElement).not.toBe(hiddenInput) + await component.getNextUpdatePromise() + expect(element.classList.contains('is-focused')).toBe(false) + }) }) function verifyCursorPosition (component, cursorNode, row, column) { @@ -180,10 +225,10 @@ function clientLeftForCharacter (component, row, column) { function lineNodeForScreenRow (component, row) { const screenLine = component.getModel().screenLineForScreenRow(row) - return component.lineNodesByScreenLine.get(screenLine) + return component.lineNodesByScreenLineId.get(screenLine.id) } function textNodesForScreenRow (component, row) { const screenLine = component.getModel().screenLineForScreenRow(row) - return component.textNodesByScreenLine.get(screenLine) + return component.textNodesByScreenLineId.get(screenLine.id) } diff --git a/src/text-editor-component.js b/src/text-editor-component.js index de5584ae98c..a70d2382153 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -26,9 +26,9 @@ class TextEditorComponent { this.measurements = null this.visible = false this.horizontalPositionsToMeasure = new Map() // Keys are rows with positions we want to measure, values are arrays of columns to measure - this.horizontalPixelPositionsByScreenLine = new WeakMap() // Values are maps from column to horiontal pixel positions - this.lineNodesByScreenLine = new WeakMap() - this.textNodesByScreenLine = new WeakMap() + this.horizontalPixelPositionsByScreenLineId = new Map() // Values are maps from column to horiontal pixel positions + this.lineNodesByScreenLineId = new Map() + this.textNodesByScreenLineId = new Map() this.cursorsToRender = [] if (this.props.model) this.observeModel() @@ -87,8 +87,18 @@ class TextEditorComponent { style = {contain: 'strict'} } - return $('atom-text-editor', {style}, - $.div({ref: 'scroller', onScroll: this.didScroll, className: 'scroll-view'}, + let className = 'editor' + if (this.focused) { + className += ' is-focused' + } + + return $('atom-text-editor', { + className, + style, + tabIndex: -1, + on: {focus: this.didFocus} + }, + $.div({ref: 'scroller', on: {scroll: this.didScroll}, className: 'scroll-view'}, $.div({ style: { isolate: 'content', @@ -212,7 +222,7 @@ class TextEditorComponent { style.width = width style.height = height children = [ - this.renderCursors(width, height), + this.renderCursorsAndInput(width, height), this.renderLineTiles(width, height) ] } else { @@ -230,7 +240,7 @@ class TextEditorComponent { renderLineTiles (width, height) { if (!this.measurements) return [] - const {lineNodesByScreenLine, textNodesByScreenLine} = this + const {lineNodesByScreenLineId, textNodesByScreenLineId} = this const firstTileStartRow = this.getFirstTileStartRow() const visibleTileCount = this.getVisibleTileCount() @@ -250,8 +260,8 @@ class TextEditorComponent { key: screenLine.id, screenLine, displayLayer, - lineNodesByScreenLine, - textNodesByScreenLine + lineNodesByScreenLineId, + textNodesByScreenLineId })) if (screenLine === this.longestLineToMeasure) { this.longestLineToMeasure = null @@ -280,8 +290,8 @@ class TextEditorComponent { key: this.longestLineToMeasure.id, screenLine: this.longestLineToMeasure, displayLayer, - lineNodesByScreenLine, - textNodesByScreenLine + lineNodesByScreenLineId, + textNodesByScreenLineId })) this.longestLineToMeasure = null } @@ -297,9 +307,23 @@ class TextEditorComponent { }, tileNodes) } - renderCursors (width, height) { + renderCursorsAndInput (width, height) { const cursorHeight = this.measurements.lineHeight + 'px' + const children = [this.renderHiddenInput()] + + for (let i = 0; i < this.cursorsToRender.length; i++) { + const {pixelLeft, pixelTop, pixelWidth} = this.cursorsToRender[i] + children.push($.div({ + className: 'cursor', + style: { + height: cursorHeight, + width: pixelWidth + 'px', + transform: `translate(${pixelLeft}px, ${pixelTop}px)` + } + })) + } + return $.div({ key: 'cursors', className: 'cursors', @@ -308,18 +332,37 @@ class TextEditorComponent { contain: 'strict', width, height } - }, - this.cursorsToRender.map(({pixelLeft, pixelTop, pixelWidth}) => - $.div({ - className: 'cursor', - style: { - height: cursorHeight, - width: pixelWidth + 'px', - transform: `translate(${pixelLeft}px, ${pixelTop}px)` - } - }) - ) - ) + }, children) + } + + renderHiddenInput () { + let top, left + const hiddenInputState = this.getHiddenInputState() + if (hiddenInputState) { + top = hiddenInputState.pixelTop + left = hiddenInputState.pixelLeft + } else { + top = 0 + left = 0 + } + + return $.input({ + ref: 'hiddenInput', + key: 'hiddenInput', + className: 'hidden-input', + on: {blur: this.didBlur}, + tabIndex: -1, + style: { + position: 'absolute', + width: '1px', + height: this.measurements.lineHeight + 'px', + top: top + 'px', + left: left + 'px', + opacity: 0, + padding: 0, + border: 0 + } + }) } queryCursorsToRender () { @@ -330,10 +373,14 @@ class TextEditorComponent { this.getRenderedEndRow() - 1, ] }) + const lastCursorMarker = model.getLastCursor().getMarker() this.cursorsToRender.length = cursorMarkers.length + this.lastCursorIndex = -1 for (let i = 0; i < cursorMarkers.length; i++) { - const screenPosition = cursorMarkers[i].getHeadScreenPosition() + const cursorMarker = cursorMarkers[i] + if (cursorMarker === lastCursorMarker) this.lastCursorIndex = i + const screenPosition = cursorMarker.getHeadScreenPosition() const {row, column} = screenPosition this.requestHorizontalMeasurement(row, column) let columnWidth = 0 @@ -367,6 +414,12 @@ class TextEditorComponent { } } + getHiddenInputState () { + if (this.lastCursorIndex >= 0) { + return this.cursorsToRender[this.lastCursorIndex] + } + } + didAttach () { this.intersectionObserver = new IntersectionObserver((entries) => { const {intersectionRect} = entries[entries.length - 1] @@ -396,6 +449,37 @@ class TextEditorComponent { } } + didFocus () { + const {hiddenInput} = this.refs + + // Ensure the input is in the visible part of the scrolled content to avoid + // the browser trying to auto-scroll to the form-field. + hiddenInput.style.top = this.measurements.scrollTop + 'px' + hiddenInput.style.left = this.measurements.scrollLeft + 'px' + + hiddenInput.focus() + this.focused = true + + // Restore the previous position of the field now that it is focused. + const currentHiddenInputState = this.getHiddenInputState() + if (currentHiddenInputState) { + hiddenInput.style.top = currentHiddenInputState.pixelTop + 'px' + hiddenInput.style.left = currentHiddenInputState.pixelLeft + 'px' + } else { + hiddenInput.style.top = 0 + hiddenInput.style.left = 0 + } + + this.scheduleUpdate() + } + + didBlur (event) { + if (this.element !== event.relatedTarget && !this.element.contains(event.relatedTarget)) { + this.focused = false + this.scheduleUpdate() + } + } + didScroll () { this.measureScrollPosition() this.updateSync() @@ -417,13 +501,18 @@ class TextEditorComponent { } measureEditorDimensions () { + let dimensionsChanged = false const scrollerHeight = this.refs.scroller.offsetHeight + const scrollerWidth = this.refs.scroller.offsetWidth if (scrollerHeight !== this.measurements.scrollerHeight) { - this.measurements.scrollerHeight = this.refs.scroller.offsetHeight - return true - } else { - return false + this.measurements.scrollerHeight = scrollerHeight + dimensionsChanged = true + } + if (scrollerWidth !== this.measurements.scrollerWidth) { + this.measurements.scrollerWidth = scrollerWidth + dimensionsChanged = true } + return dimensionsChanged } measureScrollPosition () { @@ -440,7 +529,7 @@ class TextEditorComponent { } measureLongestLineWidth (screenLine) { - this.measurements.scrollWidth = this.lineNodesByScreenLine.get(screenLine).firstChild.offsetWidth + this.measurements.scrollWidth = this.lineNodesByScreenLineId.get(screenLine.id).firstChild.offsetWidth } measureGutterDimensions () { @@ -461,12 +550,12 @@ class TextEditorComponent { columnsToMeasure.sort((a, b) => a - b) const screenLine = this.getModel().displayLayer.getScreenLine(row) - const lineNode = this.lineNodesByScreenLine.get(screenLine) - const textNodes = this.textNodesByScreenLine.get(screenLine) - let positionsForLine = this.horizontalPixelPositionsByScreenLine.get(screenLine) + const lineNode = this.lineNodesByScreenLineId.get(screenLine.id) + const textNodes = this.textNodesByScreenLineId.get(screenLine.id) + let positionsForLine = this.horizontalPixelPositionsByScreenLineId.get(screenLine.id) if (positionsForLine == null) { positionsForLine = new Map() - this.horizontalPixelPositionsByScreenLine.set(screenLine, positionsForLine) + this.horizontalPixelPositionsByScreenLineId.set(screenLine.id, positionsForLine) } this.measureHorizontalPositionsOnLine(lineNode, textNodes, columnsToMeasure, positionsForLine) @@ -478,6 +567,8 @@ class TextEditorComponent { let textNodeStartColumn = 0 let textNodesIndex = 0 + if (!textNodes) debugger + columnLoop: for (let columnsIndex = 0; columnsIndex < columnsToMeasure.length; columnsIndex++) { while (textNodesIndex < textNodes.length) { @@ -519,8 +610,11 @@ class TextEditorComponent { } pixelLeftForScreenRowAndColumn (row, column) { + if (column === 0) return 0 const screenLine = this.getModel().displayLayer.getScreenLine(row) - return this.horizontalPixelPositionsByScreenLine.get(screenLine).get(column) + + if (!this.horizontalPixelPositionsByScreenLineId.has(screenLine.id)) debugger + return this.horizontalPixelPositionsByScreenLineId.get(screenLine.id).get(column) } getModel () { @@ -622,13 +716,15 @@ class TextEditorComponent { } class LineComponent { - constructor ({displayLayer, screenLine, lineNodesByScreenLine, textNodesByScreenLine}) { + constructor (props) { + const {displayLayer, screenLine, lineNodesByScreenLineId, textNodesByScreenLineId} = props + this.props = props this.element = document.createElement('div') this.element.classList.add('line') - lineNodesByScreenLine.set(screenLine, this.element) + lineNodesByScreenLineId.set(screenLine.id, this.element) const textNodes = [] - textNodesByScreenLine.set(screenLine, textNodes) + textNodesByScreenLineId.set(screenLine.id, textNodes) const {lineText, tagCodes} = screenLine let startIndex = 0 @@ -671,6 +767,11 @@ class LineComponent { } update () {} + + destroy () { + this.props.lineNodesByScreenLineId.delete(this.props.screenLine.id) + this.props.textNodesByScreenLineId.delete(this.props.screenLine.id) + } } const classNamesByScopeName = new Map() diff --git a/static/text-editor-light.less b/static/text-editor-light.less index f8d87270cc7..d688db3c0d0 100644 --- a/static/text-editor-light.less +++ b/static/text-editor-light.less @@ -146,17 +146,6 @@ atom-text-editor { box-shadow: inset 1px 0; } - .hidden-input { - padding: 0; - border: 0; - position: absolute; - z-index: -1; - top: 0; - left: 0; - opacity: 0; - width: 1px; - } - .cursor { z-index: 4; pointer-events: none; From 4c51ae77dd7969c9a4a9aeb87157c3faf7a77120 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 28 Feb 2017 09:54:33 -0700 Subject: [PATCH 062/403] Handle text input --- src/text-editor-component.js | 81 +++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index a70d2382153..e1a10607e39 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -29,6 +29,9 @@ class TextEditorComponent { this.horizontalPixelPositionsByScreenLineId = new Map() // Values are maps from column to horiontal pixel positions this.lineNodesByScreenLineId = new Map() this.textNodesByScreenLineId = new Map() + this.lastKeydown = null + this.lastKeydownBeforeKeypress = null + this.openedAccentedCharacterMenu = false this.cursorsToRender = [] if (this.props.model) this.observeModel() @@ -350,7 +353,13 @@ class TextEditorComponent { ref: 'hiddenInput', key: 'hiddenInput', className: 'hidden-input', - on: {blur: this.didBlur}, + on: { + blur: this.didBlur, + textInput: this.didTextInput, + keydown: this.didKeydown, + keyup: this.didKeyup, + keypress: this.didKeypress + }, tabIndex: -1, style: { position: 'absolute', @@ -491,6 +500,72 @@ class TextEditorComponent { } } + didTextInput (event) { + event.stopPropagation() + + // WARNING: If we call preventDefault on the input of a space character, + // then the browser interprets the spacebar keypress as a page-down command, + // causing spaces to scroll elements containing editors. This is impossible + // to test. + if (event.data !== ' ') event.preventDefault() + + // if (!this.isInputEnabled()) return + + // Workaround of the accented character suggestion feature in macOS. This + // will only occur when the user is not composing in IME mode. When the user + // selects a modified character from the macOS menu, `textInput` will occur + // twice, once for the initial character, and once for the modified + // character. However, only a single keypress will have fired. If this is + // the case, select backward to replace the original character. + if (this.openedAccentedCharacterMenu) { + this.getModel().selectLeft() + this.openedAccentedCharacterMenu = false + } + + this.getModel().insertText(event.data, {groupUndo: true}) + } + + // We need to get clever to detect when the accented character menu is + // opened on macOS. Usually, every keydown event that could cause input is + // followed by a corresponding keypress. However, pressing and holding + // long enough to open the accented character menu causes additional keydown + // events to fire that aren't followed by their own keypress and textInput + // events. + // + // Therefore, we assume the accented character menu has been deployed if, + // before observing any keyup event, we observe events in the following + // sequence: + // + // keydown(keyCode: X), keypress, keydown(keyCode: X) + // + // The keyCode X must be the same in the keydown events that bracket the + // keypress, meaning we're *holding* the _same_ key we intially pressed. + // Got that? + didKeydown (event) { + if (this.lastKeydownBeforeKeypress != null) { + if (this.lastKeydownBeforeKeypress.keyCode === event.keyCode) { + this.openedAccentedCharacterMenu = true + } + this.lastKeydownBeforeKeypress = null + } else { + this.lastKeydown = event + } + } + + didKeypress () { + this.lastKeydownBeforeKeypress = this.lastKeydown + this.lastKeydown = null + + // This cancels the accented character behavior if we type a key normally + // with the menu open. + this.openedAccentedCharacterMenu = false + } + + didKeyup () { + this.lastKeydownBeforeKeypress = null + this.lastKeydown = null + } + performInitialMeasurements () { this.measurements = {} this.staleMeasurements = {} @@ -628,7 +703,9 @@ class TextEditorComponent { observeModel () { const {model} = this.props - this.disposables.add(model.selectionsMarkerLayer.onDidUpdate(this.scheduleUpdate.bind(this))) + const scheduleUpdate = this.scheduleUpdate.bind(this) + this.disposables.add(model.selectionsMarkerLayer.onDidUpdate(scheduleUpdate)) + this.disposables.add(model.displayLayer.onDidChangeSync(scheduleUpdate)) } isVisible () { From ff2f9b192a72dfd0993389dd5d6cc06981dce923 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 28 Feb 2017 12:21:29 -0700 Subject: [PATCH 063/403] Implement vertical autoscroll; still need tests --- spec/text-editor-component-spec.js | 71 ++++++++++--- src/text-editor-component.js | 155 +++++++++++++++++++++++++---- 2 files changed, 196 insertions(+), 30 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 99977929515..036d2ef1cb3 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -122,13 +122,13 @@ describe('TextEditorComponent', () => { expect(component.getRenderedStartRow()).toBe(4) expect(component.getRenderedEndRow()).toBe(10) - editor.setCursorScreenPosition([0, 0]) // out of view - editor.addCursorAtScreenPosition([2, 2]) // out of view - editor.addCursorAtScreenPosition([4, 0]) // line start - editor.addCursorAtScreenPosition([4, 4]) // at token boundary - editor.addCursorAtScreenPosition([4, 6]) // within token - editor.addCursorAtScreenPosition([5, Infinity]) // line end - editor.addCursorAtScreenPosition([10, 2]) // out of view + editor.setCursorScreenPosition([0, 0], {autoscroll: false}) // out of view + editor.addCursorAtScreenPosition([2, 2], {autoscroll: false}) // out of view + editor.addCursorAtScreenPosition([4, 0], {autoscroll: false}) // line start + editor.addCursorAtScreenPosition([4, 4], {autoscroll: false}) // at token boundary + editor.addCursorAtScreenPosition([4, 6], {autoscroll: false}) // within token + editor.addCursorAtScreenPosition([5, Infinity], {autoscroll: false}) // line end + editor.addCursorAtScreenPosition([10, 2], {autoscroll: false}) // out of view await component.getNextUpdatePromise() let cursorNodes = Array.from(element.querySelectorAll('.cursor')) @@ -138,14 +138,14 @@ describe('TextEditorComponent', () => { verifyCursorPosition(component, cursorNodes[2], 4, 6) verifyCursorPosition(component, cursorNodes[3], 5, 30) - editor.setCursorScreenPosition([8, 11]) + editor.setCursorScreenPosition([8, 11], {autoscroll: false}) await component.getNextUpdatePromise() cursorNodes = Array.from(element.querySelectorAll('.cursor')) expect(cursorNodes.length).toBe(1) verifyCursorPosition(component, cursorNodes[0], 8, 11) - editor.setCursorScreenPosition([0, 0]) + editor.setCursorScreenPosition([0, 0], {autoscroll: false}) await component.getNextUpdatePromise() cursorNodes = Array.from(element.querySelectorAll('.cursor')) @@ -164,8 +164,6 @@ describe('TextEditorComponent', () => { // When out of view, the hidden input is positioned at 0, 0 expect(editor.getCursorScreenPosition()).toEqual([0, 0]) - console.log(hiddenInput.offsetParent); - console.log(hiddenInput.offsetTop); expect(hiddenInput.offsetTop).toBe(0) expect(hiddenInput.offsetLeft).toBe(0) @@ -176,7 +174,7 @@ describe('TextEditorComponent', () => { expect(Math.round(hiddenInput.getBoundingClientRect().left)).toBe(clientLeftForCharacter(component, 7, 4)) }) - it('focuses the hidden input elemnent and adds the is-focused class when focused', async () => { + it('focuses the hidden input element and adds the is-focused class when focused', async () => { const {component, element, editor} = buildComponent() const {hiddenInput} = component.refs @@ -196,6 +194,55 @@ describe('TextEditorComponent', () => { await component.getNextUpdatePromise() expect(element.classList.contains('is-focused')).toBe(false) }) + + describe('autoscroll', () => { + it('automatically scrolls vertically when the cursor is within vertical scroll margin of the top or bottom', async () => { + const {component, element, editor} = buildComponent({height: 120}) + const {scroller} = component.refs + expect(component.getLastVisibleRow()).toBe(8) + + editor.setCursorScreenPosition([6, 0]) + await component.getNextUpdatePromise() + let scrollBottom = scroller.scrollTop + scroller.clientHeight + expect(scrollBottom).toBe((6 + 1 + editor.verticalScrollMargin) * component.measurements.lineHeight) + + editor.setCursorScreenPosition([8, 0]) + await component.getNextUpdatePromise() + scrollBottom = scroller.scrollTop + scroller.clientHeight + expect(scrollBottom).toBe((8 + 1 + editor.verticalScrollMargin) * component.measurements.lineHeight) + + editor.setCursorScreenPosition([3, 0]) + await component.getNextUpdatePromise() + expect(scroller.scrollTop).toBe((3 - editor.verticalScrollMargin) * component.measurements.lineHeight) + + editor.setCursorScreenPosition([2, 0]) + await component.getNextUpdatePromise() + expect(scroller.scrollTop).toBe(0) + }) + + it('does not vertically autoscroll by more than half of the visible lines if the editor is shorter than twice the scroll margin', async () => { + const {component, element, editor} = buildComponent() + const {scroller} = component.refs + element.style.height = 5.5 * component.measurements.lineHeight + 'px' + await component.getNextUpdatePromise() + expect(component.getLastVisibleRow()).toBe(6) + const scrollMarginInLines = 2 + + editor.setCursorScreenPosition([6, 0]) + await component.getNextUpdatePromise() + let scrollBottom = scroller.scrollTop + scroller.clientHeight + expect(scrollBottom).toBe((6 + 1 + scrollMarginInLines) * component.measurements.lineHeight) + + editor.setCursorScreenPosition([6, 4]) + await component.getNextUpdatePromise() + scrollBottom = scroller.scrollTop + scroller.clientHeight + expect(scrollBottom).toBe((6 + 1 + scrollMarginInLines) * component.measurements.lineHeight) + + editor.setCursorScreenPosition([4, 4]) + await component.getNextUpdatePromise() + expect(scroller.scrollTop).toBe((4 - scrollMarginInLines) * component.measurements.lineHeight) + }) + }) }) function verifyCursorPosition (component, cursorNode, row, column) { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index e1a10607e39..a226d496ec8 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -29,6 +29,11 @@ class TextEditorComponent { this.horizontalPixelPositionsByScreenLineId = new Map() // Values are maps from column to horiontal pixel positions this.lineNodesByScreenLineId = new Map() this.textNodesByScreenLineId = new Map() + this.pendingAutoscroll = null + this.autoscrollTop = -1 + this.scrollWidthOrHeightChanged = false + this.previousScrollWidth = 0 + this.previousScrollHeight = 0 this.lastKeydown = null this.lastKeydownBeforeKeypress = null this.openedAccentedCharacterMenu = false @@ -64,7 +69,10 @@ class TextEditorComponent { this.resolveNextUpdatePromise = null } - if (this.staleMeasurements.editorDimensions) this.measureEditorDimensions() + if (this.scrollWidthOrHeightChanged) { + this.measureClientDimensions() + this.scrollWidthOrHeightChanged = false + } const longestLine = this.getLongestScreenLine() let measureLongestLine = false @@ -74,14 +82,27 @@ class TextEditorComponent { measureLongestLine = true } + if (this.pendingAutoscroll) { + this.autoscrollVertically() + } + this.horizontalPositionsToMeasure.clear() etch.updateSync(this) - if (measureLongestLine) this.measureLongestLineWidth(longestLine) + + if (this.autoscrollTop >= 0) { + this.refs.scroller.scrollTop = this.autoscrollTop + this.autoscrollTop = -1 + } + if (measureLongestLine) { + this.measureLongestLineWidth(longestLine) + } this.queryCursorsToRender() this.measureHorizontalPositions() this.positionCursorsToRender() etch.updateSync(this) + + this.pendingAutoscroll = null } render () { @@ -220,8 +241,16 @@ class TextEditorComponent { overflow: 'hidden' } if (this.measurements) { - const width = this.measurements.scrollWidth + 'px' - const height = this.getScrollHeight() + 'px' + const scrollWidth = this.getScrollWidth() + const scrollHeight = this.getScrollHeight() + if (scrollWidth !== this.previousScrollWidth || scrollHeight !== this.previousScrollHeight) { + this.scrollWidthOrHeightChanged = true + this.previousScrollWidth = scrollWidth + this.previousScrollHeight = scrollHeight + } + + const width = scrollWidth + 'px' + const height = scrollHeight + 'px' style.width = width style.height = height children = [ @@ -280,7 +309,7 @@ class TextEditorComponent { contain: 'strict', position: 'absolute', height: tileHeight + 'px', - width: this.measurements.scrollWidth + 'px', + width: width, willChange: 'transform', transform: `translateY(${this.topPixelPositionForRow(tileStartRow)}px)`, backgroundColor: 'inherit' @@ -382,6 +411,7 @@ class TextEditorComponent { this.getRenderedEndRow() - 1, ] }) + if (global.debug) debugger const lastCursorMarker = model.getLastCursor().getMarker() this.cursorsToRender.length = cursorMarkers.length @@ -463,8 +493,8 @@ class TextEditorComponent { // Ensure the input is in the visible part of the scrolled content to avoid // the browser trying to auto-scroll to the form-field. - hiddenInput.style.top = this.measurements.scrollTop + 'px' - hiddenInput.style.left = this.measurements.scrollLeft + 'px' + hiddenInput.style.top = this.getScrollTop() + 'px' + hiddenInput.style.left = this.getScrollLeft() + 'px' hiddenInput.focus() this.focused = true @@ -490,12 +520,14 @@ class TextEditorComponent { } didScroll () { - this.measureScrollPosition() - this.updateSync() + if (this.measureScrollPosition()) { + this.updateSync() + } } didResize () { if (this.measureEditorDimensions()) { + this.measureClientDimensions() this.scheduleUpdate() } } @@ -566,10 +598,60 @@ class TextEditorComponent { this.lastKeydown = null } + didRequestAutoscroll (autoscroll) { + this.pendingAutoscroll = autoscroll + this.scheduleUpdate() + } + + autoscrollVertically () { + const {screenRange, options} = this.pendingAutoscroll + + const screenRangeTop = this.pixelTopForScreenRow(screenRange.start.row) + const screenRangeBottom = this.pixelTopForScreenRow(screenRange.end.row) + this.measurements.lineHeight + const verticalScrollMargin = this.getVerticalScrollMargin() + + let desiredScrollTop, desiredScrollBottom + if (options && options.center) { + const desiredScrollCenter = (screenRangeTop + screenRangeBottom) / 2 + if (desiredScrollCenter < this.getScrollTop() || desiredScrollCenter > this.getScrollBottom()) { + desiredScrollTop = desiredScrollCenter - this.measurements.clientHeight / 2 + desiredScrollBottom = desiredScrollCenter + this.measurements.clientHeight / 2 + } + } else { + desiredScrollTop = screenRangeTop - verticalScrollMargin + desiredScrollBottom = screenRangeBottom + verticalScrollMargin + } + + if (!options || options.reversed !== false) { + if (desiredScrollBottom > this.getScrollBottom()) { + this.autoscrollTop = desiredScrollBottom - this.measurements.clientHeight + } + if (desiredScrollTop < this.getScrollTop()) { + this.autoscrollTop = desiredScrollTop + } + } else { + if (desiredScrollTop < this.getScrollTop()) { + this.autoscrollTop = desiredScrollTop + } + if (desiredScrollBottom > this.getScrollBottom()) { + this.autoscrollTop = desiredScrollBottom - this.measurements.clientHeight + } + } + } + + getVerticalScrollMargin () { + const {clientHeight, lineHeight} = this.measurements + const marginInLines = Math.min( + this.getModel().verticalScrollMargin, + Math.floor(((clientHeight / lineHeight) - 1) / 2) + ) + return marginInLines * this.measurements.lineHeight + } + performInitialMeasurements () { this.measurements = {} - this.staleMeasurements = {} this.measureEditorDimensions() + this.measureClientDimensions() this.measureScrollPosition() this.measureCharacterDimensions() this.measureGutterDimensions() @@ -591,8 +673,31 @@ class TextEditorComponent { } measureScrollPosition () { - this.measurements.scrollTop = this.refs.scroller.scrollTop - this.measurements.scrollLeft = this.refs.scroller.scrollLeft + let scrollPositionChanged = false + const {scrollTop, scrollLeft} = this.refs.scroller + if (scrollTop !== this.measurements.scrollTop) { + this.measurements.scrollTop = scrollTop + scrollPositionChanged = true + } + if (scrollLeft !== this.measurements.scrollLeft) { + this.measurements.scrollLeft = scrollLeft + scrollPositionChanged = true + } + return scrollPositionChanged + } + + measureClientDimensions () { + let clientDimensionsChanged = false + const {clientHeight, clientWidth} = this.refs.scroller + if (clientHeight !== this.measurements.clientHeight) { + this.measurements.clientHeight = clientHeight + clientDimensionsChanged = true + } + if (clientWidth !== this.measurements.clientWidth) { + this.measurements.clientWidth = clientWidth + clientDimensionsChanged = true + } + return clientDimensionsChanged } measureCharacterDimensions () { @@ -604,7 +709,7 @@ class TextEditorComponent { } measureLongestLineWidth (screenLine) { - this.measurements.scrollWidth = this.lineNodesByScreenLineId.get(screenLine.id).firstChild.offsetWidth + this.measurements.longestLineWidth = this.lineNodesByScreenLineId.get(screenLine.id).firstChild.offsetWidth } measureGutterDimensions () { @@ -642,8 +747,6 @@ class TextEditorComponent { let textNodeStartColumn = 0 let textNodesIndex = 0 - if (!textNodes) debugger - columnLoop: for (let columnsIndex = 0; columnsIndex < columnsToMeasure.length; columnsIndex++) { while (textNodesIndex < textNodes.length) { @@ -706,6 +809,7 @@ class TextEditorComponent { const scheduleUpdate = this.scheduleUpdate.bind(this) this.disposables.add(model.selectionsMarkerLayer.onDidUpdate(scheduleUpdate)) this.disposables.add(model.displayLayer.onDidChangeSync(scheduleUpdate)) + this.disposables.add(model.onDidRequestAutoscroll(this.didRequestAutoscroll.bind(this))) } isVisible () { @@ -717,7 +821,17 @@ class TextEditorComponent { } getScrollTop () { - return this.measurements ? this.measurements.scrollTop : null + if (this.autoscrollTop >= 0) { + return this.autoscrollTop + } else if (this.measurements != null) { + return this.measurements.scrollTop + } + } + + getScrollBottom () { + return this.measurements + ? this.getScrollTop() + this.measurements.clientHeight + : null } getScrollLeft () { @@ -753,12 +867,13 @@ class TextEditorComponent { } getFirstVisibleRow () { - const {scrollTop, lineHeight} = this.measurements + const scrollTop = this.getScrollTop() + const lineHeight = this.measurements.lineHeight return Math.floor(scrollTop / lineHeight) } getLastVisibleRow () { - const {scrollTop, scrollerHeight, lineHeight} = this.measurements + const {scrollerHeight, lineHeight} = this.measurements return Math.min( this.getModel().getApproximateScreenLineCount() - 1, this.getFirstVisibleRow() + Math.ceil(scrollerHeight / lineHeight) @@ -769,6 +884,10 @@ class TextEditorComponent { return row * this.measurements.lineHeight } + getScrollWidth () { + return Math.round(this.measurements.longestLineWidth + this.measurements.baseCharacterWidth) + } + getScrollHeight () { return this.getModel().getApproximateScreenLineCount() * this.measurements.lineHeight } From ec045d933316100633e23fb8742e7e5e2eae9a80 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 28 Feb 2017 16:47:15 -0700 Subject: [PATCH 064/403] Gracefully handle focus events that occur before the attachedCallback --- spec/text-editor-component-spec.js | 55 +++++++++++++++++++++--------- src/text-editor-component.js | 28 +++++++++------ 2 files changed, 56 insertions(+), 27 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 036d2ef1cb3..ba068fa7569 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -11,6 +11,16 @@ const path = require('path') const SAMPLE_TEXT = fs.readFileSync(path.join(__dirname, 'fixtures', 'sample.js'), 'utf8') const NBSP_CHARACTER = '\u00a0' +document.registerElement('text-editor-component-test-element', { + prototype: Object.create(HTMLElement.prototype, { + attachedCallback: { + value: function () { + this.didAttach() + } + } + }) +}) + describe('TextEditorComponent', () => { beforeEach(() => { jasmine.useRealClock() @@ -27,7 +37,7 @@ describe('TextEditorComponent', () => { const {element} = component element.style.width = params.width ? params.width + 'px' : '800px' element.style.height = params.height ? params.height + 'px' : '600px' - jasmine.attachToDOM(element) + if (params.attach !== false) jasmine.attachToDOM(element) return {component, element, editor} } @@ -174,25 +184,36 @@ describe('TextEditorComponent', () => { expect(Math.round(hiddenInput.getBoundingClientRect().left)).toBe(clientLeftForCharacter(component, 7, 4)) }) - it('focuses the hidden input element and adds the is-focused class when focused', async () => { - const {component, element, editor} = buildComponent() - const {hiddenInput} = component.refs + describe('focus', () => { + it('focuses the hidden input element and adds the is-focused class when focused', async () => { + const {component, element, editor} = buildComponent() + const {hiddenInput} = component.refs - expect(document.activeElement).not.toBe(hiddenInput) - element.focus() - expect(document.activeElement).toBe(hiddenInput) - await component.getNextUpdatePromise() - expect(element.classList.contains('is-focused')).toBe(true) + expect(document.activeElement).not.toBe(hiddenInput) + element.focus() + expect(document.activeElement).toBe(hiddenInput) + await component.getNextUpdatePromise() + expect(element.classList.contains('is-focused')).toBe(true) - element.focus() // focusing back to the element does not blur - expect(document.activeElement).toBe(hiddenInput) - await component.getNextUpdatePromise() - expect(element.classList.contains('is-focused')).toBe(true) + element.focus() // focusing back to the element does not blur + expect(document.activeElement).toBe(hiddenInput) + await component.getNextUpdatePromise() + expect(element.classList.contains('is-focused')).toBe(true) - document.body.focus() - expect(document.activeElement).not.toBe(hiddenInput) - await component.getNextUpdatePromise() - expect(element.classList.contains('is-focused')).toBe(false) + document.body.focus() + expect(document.activeElement).not.toBe(hiddenInput) + await component.getNextUpdatePromise() + expect(element.classList.contains('is-focused')).toBe(false) + }) + + it('gracefully handles a focus event that occurs prior to the attachedCallback of the element', () => { + const {component, element, editor} = buildComponent({attach: false}) + const parent = document.createElement('text-editor-component-test-element') + parent.appendChild(element) + parent.didAttach = () => element.focus() + jasmine.attachToDOM(parent) + expect(document.activeElement).toBe(component.refs.hiddenInput) + }) }) describe('autoscroll', () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index a226d496ec8..b7f3231f0ef 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -460,16 +460,19 @@ class TextEditorComponent { } didAttach () { - this.intersectionObserver = new IntersectionObserver((entries) => { - const {intersectionRect} = entries[entries.length - 1] - if (intersectionRect.width > 0 || intersectionRect.height > 0) { - this.didShow() - } else { - this.didHide() - } - }) - this.intersectionObserver.observe(this.element) - if (this.isVisible()) this.didShow() + if (!this.attached) { + this.attached = true + this.intersectionObserver = new IntersectionObserver((entries) => { + const {intersectionRect} = entries[entries.length - 1] + if (intersectionRect.width > 0 || intersectionRect.height > 0) { + this.didShow() + } else { + this.didHide() + } + }) + this.intersectionObserver.observe(this.element) + if (this.isVisible()) this.didShow() + } } didShow () { @@ -489,6 +492,11 @@ class TextEditorComponent { } didFocus () { + // This element can be focused from a parent custom element's + // attachedCallback before *its* attachedCallback is fired. This protects + // against that case. + if (!this.attached) this.didAttach() + const {hiddenInput} = this.refs // Ensure the input is in the visible part of the scrolled content to avoid From d929720d2471ac14c6448cc3ffda70db4dd26b6f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 28 Feb 2017 17:15:46 -0700 Subject: [PATCH 065/403] Use null sentinel value for autoscrollTop to avoid bug with negatives --- src/text-editor-component.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index b7f3231f0ef..f0fc7eb1d45 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -30,7 +30,7 @@ class TextEditorComponent { this.lineNodesByScreenLineId = new Map() this.textNodesByScreenLineId = new Map() this.pendingAutoscroll = null - this.autoscrollTop = -1 + this.autoscrollTop = null this.scrollWidthOrHeightChanged = false this.previousScrollWidth = 0 this.previousScrollHeight = 0 @@ -89,9 +89,9 @@ class TextEditorComponent { this.horizontalPositionsToMeasure.clear() etch.updateSync(this) - if (this.autoscrollTop >= 0) { + if (this.autoscrollTop != null) { this.refs.scroller.scrollTop = this.autoscrollTop - this.autoscrollTop = -1 + this.autoscrollTop = null } if (measureLongestLine) { this.measureLongestLineWidth(longestLine) @@ -411,7 +411,6 @@ class TextEditorComponent { this.getRenderedEndRow() - 1, ] }) - if (global.debug) debugger const lastCursorMarker = model.getLastCursor().getMarker() this.cursorsToRender.length = cursorMarkers.length @@ -829,7 +828,7 @@ class TextEditorComponent { } getScrollTop () { - if (this.autoscrollTop >= 0) { + if (this.autoscrollTop != null) { return this.autoscrollTop } else if (this.measurements != null) { return this.measurements.scrollTop From 19db16664fa0b9de2101afa64eae6de63c0dc5c1 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 28 Feb 2017 17:44:52 -0700 Subject: [PATCH 066/403] Don't autoscroll to impossible scrollTop locations --- src/text-editor-component.js | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index f0fc7eb1d45..78c3655f9f5 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -629,6 +629,14 @@ class TextEditorComponent { desiredScrollBottom = screenRangeBottom + verticalScrollMargin } + if (desiredScrollTop != null) { + desiredScrollTop = Math.max(0, Math.min(desiredScrollTop, this.getScrollHeight() - this.getClientHeight())) + } + + if (desiredScrollBottom != null) { + desiredScrollBottom = Math.max(this.getClientHeight(), Math.min(desiredScrollBottom, this.getScrollHeight())) + } + if (!options || options.reversed !== false) { if (desiredScrollBottom > this.getScrollBottom()) { this.autoscrollTop = desiredScrollBottom - this.measurements.clientHeight @@ -845,6 +853,18 @@ class TextEditorComponent { return this.measurements ? this.measurements.scrollLeft : null } + getScrollHeight () { + return this.getModel().getApproximateScreenLineCount() * this.measurements.lineHeight + } + + getScrollWidth () { + return Math.round(this.measurements.longestLineWidth + this.measurements.baseCharacterWidth) + } + + getClientHeight () { + return this.measurements.clientHeight + } + getRowsPerTile () { return this.props.rowsPerTile || DEFAULT_ROWS_PER_TILE } @@ -891,14 +911,6 @@ class TextEditorComponent { return row * this.measurements.lineHeight } - getScrollWidth () { - return Math.round(this.measurements.longestLineWidth + this.measurements.baseCharacterWidth) - } - - getScrollHeight () { - return this.getModel().getApproximateScreenLineCount() * this.measurements.lineHeight - } - getLongestScreenLine () { const model = this.getModel() // Ensure the spatial index is populated with rows that are currently From b8a3e2f163af3782dff78a24ab2ae3fcef26bd36 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 28 Feb 2017 18:12:17 -0700 Subject: [PATCH 067/403] Don't clear elements owned by other components from line nodes map We should really be recycling elements when they move between lines, but that's a bigger project. --- src/text-editor-component.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 78c3655f9f5..cbee96c9494 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -984,8 +984,11 @@ class LineComponent { update () {} destroy () { - this.props.lineNodesByScreenLineId.delete(this.props.screenLine.id) - this.props.textNodesByScreenLineId.delete(this.props.screenLine.id) + const {lineNodesByScreenLineId, textNodesByScreenLineId, screenLine} = this.props + if (lineNodesByScreenLineId.get(screenLine.id) === this.element) { + lineNodesByScreenLineId.delete(screenLine.id) + textNodesByScreenLineId.delete(screenLine.id) + } } } From 192e7c6b637420714376fb171504cbfbf465c365 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 28 Feb 2017 20:17:53 -0700 Subject: [PATCH 068/403] Handle direct focus of hidden input and avoid redundant focus renders --- spec/text-editor-component-spec.js | 12 ++++++++++- src/text-editor-component.js | 33 +++++++++++++++++++++--------- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index ba068fa7569..9892a4c1e77 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -197,7 +197,6 @@ describe('TextEditorComponent', () => { element.focus() // focusing back to the element does not blur expect(document.activeElement).toBe(hiddenInput) - await component.getNextUpdatePromise() expect(element.classList.contains('is-focused')).toBe(true) document.body.focus() @@ -206,6 +205,17 @@ describe('TextEditorComponent', () => { expect(element.classList.contains('is-focused')).toBe(false) }) + it('updates the component when the hidden input is focused directly', async () => { + const {component, element, editor} = buildComponent() + const {hiddenInput} = component.refs + expect(element.classList.contains('is-focused')).toBe(false) + expect(document.activeElement).not.toBe(hiddenInput) + + hiddenInput.focus() + await component.getNextUpdatePromise() + expect(element.classList.contains('is-focused')).toBe(true) + }) + it('gracefully handles a focus event that occurs prior to the attachedCallback of the element', () => { const {component, element, editor} = buildComponent({attach: false}) const parent = document.createElement('text-editor-component-test-element') diff --git a/src/text-editor-component.js b/src/text-editor-component.js index cbee96c9494..1b85025f5e6 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -383,7 +383,8 @@ class TextEditorComponent { key: 'hiddenInput', className: 'hidden-input', on: { - blur: this.didBlur, + blur: this.didBlurHiddenInput, + focus: this.didFocusHiddenInput, textInput: this.didTextInput, keydown: this.didKeydown, keyup: this.didKeyup, @@ -496,17 +497,22 @@ class TextEditorComponent { // against that case. if (!this.attached) this.didAttach() - const {hiddenInput} = this.refs + if (!this.focused) { + this.focused = true + this.scheduleUpdate() + } - // Ensure the input is in the visible part of the scrolled content to avoid - // the browser trying to auto-scroll to the form-field. + // Transfer focus to the hidden input, but first ensure the input is in the + // visible part of the scrolled content to avoid the browser trying to + // auto-scroll to the form-field. + const {hiddenInput} = this.refs hiddenInput.style.top = this.getScrollTop() + 'px' hiddenInput.style.left = this.getScrollLeft() + 'px' hiddenInput.focus() - this.focused = true - // Restore the previous position of the field now that it is focused. + // Restore the previous position of the field now that it is already focused + // and won't cause unwanted scrolling. const currentHiddenInputState = this.getHiddenInputState() if (currentHiddenInputState) { hiddenInput.style.top = currentHiddenInputState.pixelTop + 'px' @@ -515,19 +521,26 @@ class TextEditorComponent { hiddenInput.style.top = 0 hiddenInput.style.left = 0 } - - this.scheduleUpdate() } - didBlur (event) { + didBlurHiddenInput (event) { if (this.element !== event.relatedTarget && !this.element.contains(event.relatedTarget)) { + console.log('blur hi'); this.focused = false this.scheduleUpdate() } } + didFocusHiddenInput () { + if (!this.focused) { + console.log('focus hi'); + this.focused = true + this.scheduleUpdate() + } + } + didScroll () { - if (this.measureScrollPosition()) { + if (this.measureScrollPosition(true)) { this.updateSync() } } From 55ed9e4f62b5add55bc8efbc1ffbd264a44eb322 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 28 Feb 2017 20:19:06 -0700 Subject: [PATCH 069/403] Pre-assign measuremets.scrollTop when autoscrolling This avoids work when the scroll event happens asynchronously because we'll treat the event as a no-op since the measurements didn't change. --- src/text-editor-component.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 1b85025f5e6..610933556cc 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -665,6 +665,10 @@ class TextEditorComponent { this.autoscrollTop = desiredScrollBottom - this.measurements.clientHeight } } + + if (this.autoscrollTop != null) { + this.measurements.scrollTop = this.autoscrollTop + } } getVerticalScrollMargin () { @@ -849,9 +853,7 @@ class TextEditorComponent { } getScrollTop () { - if (this.autoscrollTop != null) { - return this.autoscrollTop - } else if (this.measurements != null) { + if (this.measurements != null) { return this.measurements.scrollTop } } From c22a81dc5722c6946db7ab1405406e6246987ca2 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 28 Feb 2017 20:20:47 -0700 Subject: [PATCH 070/403] Remove logging --- src/text-editor-component.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 610933556cc..f0724a182f9 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -525,7 +525,6 @@ class TextEditorComponent { didBlurHiddenInput (event) { if (this.element !== event.relatedTarget && !this.element.contains(event.relatedTarget)) { - console.log('blur hi'); this.focused = false this.scheduleUpdate() } @@ -533,7 +532,6 @@ class TextEditorComponent { didFocusHiddenInput () { if (!this.focused) { - console.log('focus hi'); this.focused = true this.scheduleUpdate() } From c2dcc0121b71e770f4c542c975129efae5296e1b Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 1 Mar 2017 08:44:17 -0700 Subject: [PATCH 071/403] Add a key to line number divs --- src/text-editor-component.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index f0724a182f9..cc96c9fc00f 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -181,6 +181,7 @@ class TextEditorComponent { children = new Array(visibleTileCount) let previousBufferRow = (firstTileStartRow > 0) ? this.getModel().bufferRowForScreenRow(firstTileStartRow - 1) : -1 + let softWrapCount = 0 for (let tileStartRow = firstTileStartRow; tileStartRow <= lastTileStartRow; tileStartRow += this.getRowsPerTile()) { const currentTileEndRow = tileStartRow + this.getRowsPerTile() const lineNumberNodes = [] @@ -188,7 +189,16 @@ class TextEditorComponent { for (let row = tileStartRow; row < currentTileEndRow && row <= approximateLastScreenRow; row++) { const bufferRow = this.getModel().bufferRowForScreenRow(row) const foldable = this.getModel().isFoldableAtBufferRow(bufferRow) - const softWrapped = (bufferRow === previousBufferRow) + let softWrapped = false + let key + if (bufferRow === previousBufferRow) { + softWrapped = true + softWrapCount++ + key = `${bufferRow}-${softWrapCount}` + } else { + softWrapCount = 0 + key = bufferRow + } let className = 'line-number' let lineNumber @@ -200,7 +210,7 @@ class TextEditorComponent { } lineNumber = NBSP_CHARACTER.repeat(maxLineNumberDigits - lineNumber.length) + lineNumber - lineNumberNodes.push($.div({className}, + lineNumberNodes.push($.div({key, className}, lineNumber, $.div({className: 'icon-right'}) )) From eae8e15155de36308080732c25032ed536144d34 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 1 Mar 2017 12:34:21 -0700 Subject: [PATCH 072/403] Extract LineNumberGutterComponent to reduce patching --- src/text-editor-component.js | 226 ++++++++++++++++++++++------------- 1 file changed, 146 insertions(+), 80 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index cc96c9fc00f..693208d0cdf 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -154,94 +154,49 @@ class TextEditorComponent { } renderLineNumberGutter () { - const maxLineNumberDigits = Math.max(2, this.getModel().getLineCount().toString().length) - - let props = { - ref: 'lineNumberGutter', - className: 'gutter line-numbers', - 'gutter-name': 'line-number' - } - let children + const model = this.getModel() + const maxLineNumberDigits = Math.max(2, model.getLineCount().toString().length) if (this.measurements) { - props.style = { - contain: 'strict', - overflow: 'hidden', - height: this.getScrollHeight() + 'px', - width: this.measurements.lineNumberGutterWidth + 'px' + const startRow = this.getRenderedStartRow() + const endRow = this.getRenderedEndRow() + + const bufferRows = new Array(endRow - startRow) + const foldableFlags = new Array(endRow - startRow) + const softWrappedFlags = new Array(endRow - startRow) + + let previousBufferRow = (startRow > 0) ? model.bufferRowForScreenRow(startRow - 1) : -1 + for (let row = startRow; row < endRow; row++) { + const i = row - startRow + const bufferRow = model.bufferRowForScreenRow(row) + bufferRows[i] = bufferRow + softWrappedFlags[i] = bufferRow === previousBufferRow + foldableFlags[i] = model.isFoldableAtBufferRow(bufferRow) + previousBufferRow = bufferRow } - const approximateLastScreenRow = this.getModel().getApproximateScreenLineCount() - 1 - const firstVisibleRow = this.getFirstVisibleRow() - const lastVisibleRow = this.getLastVisibleRow() - const firstTileStartRow = this.getFirstTileStartRow() - const visibleTileCount = this.getVisibleTileCount() - const lastTileStartRow = this.getLastTileStartRow() - - children = new Array(visibleTileCount) - - let previousBufferRow = (firstTileStartRow > 0) ? this.getModel().bufferRowForScreenRow(firstTileStartRow - 1) : -1 - let softWrapCount = 0 - for (let tileStartRow = firstTileStartRow; tileStartRow <= lastTileStartRow; tileStartRow += this.getRowsPerTile()) { - const currentTileEndRow = tileStartRow + this.getRowsPerTile() - const lineNumberNodes = [] - - for (let row = tileStartRow; row < currentTileEndRow && row <= approximateLastScreenRow; row++) { - const bufferRow = this.getModel().bufferRowForScreenRow(row) - const foldable = this.getModel().isFoldableAtBufferRow(bufferRow) - let softWrapped = false - let key - if (bufferRow === previousBufferRow) { - softWrapped = true - softWrapCount++ - key = `${bufferRow}-${softWrapCount}` - } else { - softWrapCount = 0 - key = bufferRow - } - - let className = 'line-number' - let lineNumber - if (softWrapped) { - lineNumber = '•' - } else { - if (foldable) className += ' foldable' - lineNumber = (bufferRow + 1).toString() - } - lineNumber = NBSP_CHARACTER.repeat(maxLineNumberDigits - lineNumber.length) + lineNumber - - lineNumberNodes.push($.div({key, className}, - lineNumber, - $.div({className: 'icon-right'}) - )) + const rowsPerTile = this.getRowsPerTile() - previousBufferRow = bufferRow - } - - const tileIndex = (tileStartRow / this.getRowsPerTile()) % visibleTileCount - const tileHeight = this.getRowsPerTile() * this.measurements.lineHeight - - children[tileIndex] = $.div({ - style: { - contain: 'strict', - overflow: 'hidden', - position: 'absolute', - height: tileHeight + 'px', - width: this.measurements.lineNumberGutterWidth + 'px', - willChange: 'transform', - transform: `translateY(${this.topPixelPositionForRow(tileStartRow)}px)`, - backgroundColor: 'inherit' - } - }, lineNumberNodes) - } + return $(LineNumberGutterComponent, { + height: this.getScrollHeight(), + width: this.measurements.lineNumberGutterWidth, + lineHeight: this.measurements.lineHeight, + startRow, endRow, rowsPerTile, maxLineNumberDigits, + bufferRows, softWrappedFlags, foldableFlags + }) } else { - children = $.div({className: 'line-number'}, - '0'.repeat(maxLineNumberDigits), - $.div({className: 'icon-right'}) + return $.div( + { + ref: 'lineNumberGutter', + className: 'gutter line-numbers', + 'gutter-name': 'line-number' + }, + $.div({className: 'line-number'}, + '0'.repeat(maxLineNumberDigits), + $.div({className: 'icon-right'}) + ) ) } - - return $.div(props, children) } renderContent () { @@ -953,6 +908,109 @@ class TextEditorComponent { } } +class LineNumberGutterComponent { + constructor (props) { + this.props = props + etch.initialize(this) + } + + update (newProps) { + if (this.shouldUpdate(newProps)) { + this.props = newProps + etch.updateSync(this) + } + } + + render () { + const { + height, width, lineHeight, startRow, endRow, rowsPerTile, + maxLineNumberDigits, bufferRows, softWrappedFlags, foldableFlags + } = this.props + + const visibleTileCount = (endRow - startRow) / rowsPerTile + const children = new Array(visibleTileCount) + const tileHeight = rowsPerTile * lineHeight + 'px' + const tileWidth = width + 'px' + + let softWrapCount = 0 + for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow += rowsPerTile) { + const tileChildren = new Array(rowsPerTile) + const tileEndRow = tileStartRow + rowsPerTile + for (let row = tileStartRow; row < tileEndRow; row++) { + const i = row - startRow + const bufferRow = bufferRows[i] + const softWrapped = softWrappedFlags[i] + const foldable = foldableFlags[i] + let key, lineNumber + let className = 'line-number' + if (softWrapped) { + softWrapCount++ + key = `${bufferRow}-${softWrapCount}` + lineNumber = '•' + } else { + softWrapCount = 0 + key = bufferRow + lineNumber = (bufferRow + 1).toString() + if (foldable) className += ' foldable' + } + lineNumber = NBSP_CHARACTER.repeat(maxLineNumberDigits - lineNumber.length) + lineNumber + + tileChildren[row - tileStartRow] = $.div({key, className}, + lineNumber, + $.div({className: 'icon-right'}) + ) + } + + const tileIndex = (tileStartRow / rowsPerTile) % visibleTileCount + const top = tileStartRow * lineHeight + + children[tileIndex] = $.div({ + key: tileIndex, + style: { + contain: 'strict', + overflow: 'hidden', + position: 'absolute', + height: tileHeight, + width: tileWidth, + willChange: 'transform', + transform: `translateY(${top}px)`, + backgroundColor: 'inherit' + } + }, ...tileChildren) + } + + return $.div( + { + className: 'gutter line-numbers', + 'gutter-name': 'line-number', + style: { + contain: 'strict', + overflow: 'hidden', + height: height + 'px', + width: tileWidth + } + }, + ...children + ) + } + + shouldUpdate (newProps) { + const oldProps = this.props + + if (oldProps.height !== newProps.height) return true + if (oldProps.width !== newProps.width) return true + if (oldProps.lineHeight !== newProps.lineHeight) return true + if (oldProps.startRow !== newProps.startRow) return true + if (oldProps.endRow !== newProps.endRow) return true + if (oldProps.rowsPerTile !== newProps.rowsPerTile) return true + if (oldProps.maxLineNumberDigits !== newProps.maxLineNumberDigits) return true + if (!arraysEqual(oldProps.bufferRows, newProps.bufferRows)) return true + if (!arraysEqual(oldProps.softWrappedFlags, newProps.softWrappedFlags)) return true + if (!arraysEqual(oldProps.foldableFlags, newProps.foldableFlags)) return true + return false + } +} + class LineComponent { constructor (props) { const {displayLayer, screenLine, lineNodesByScreenLineId, textNodesByScreenLineId} = props @@ -1030,3 +1088,11 @@ function getRangeForMeasurement () { if (!rangeForMeasurement) rangeForMeasurement = document.createRange() return rangeForMeasurement } + +function arraysEqual(a, b) { + if (a.length !== b.length) return false + for (let i = 0, length = a.length; i < length; i++) { + if (a[i] !== b[i]) return false + } + return true +} From 38f51ce74d60f9b350ee3f91e0c7efeafa48726e Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 1 Mar 2017 13:15:39 -0700 Subject: [PATCH 073/403] Extract LinesTileComponent to minimize diff/patch overhead When typing on a single line, only a single tile needs to be updated. When moving the cursor no tiles need to be updated. --- src/text-editor-component.js | 128 ++++++++++++++++++++++++----------- 1 file changed, 89 insertions(+), 39 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 693208d0cdf..3a9e1cf428f 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -74,10 +74,12 @@ class TextEditorComponent { this.scrollWidthOrHeightChanged = false } - const longestLine = this.getLongestScreenLine() + const longestLineRow = this.getLongestScreenLineRow() + const longestLine = this.getModel().screenLineForScreenRow(longestLineRow) let measureLongestLine = false if (longestLine !== this.previousLongestLine) { this.longestLineToMeasure = longestLine + this.longestLineToMeasureRow = longestLineRow this.previousLongestLine = longestLine measureLongestLine = true } @@ -95,6 +97,8 @@ class TextEditorComponent { } if (measureLongestLine) { this.measureLongestLineWidth(longestLine) + this.longestLineToMeasureRow = null + this.longestLineToMeasure = null } this.queryCursorsToRender() this.measureHorizontalPositions() @@ -239,50 +243,38 @@ class TextEditorComponent { const {lineNodesByScreenLineId, textNodesByScreenLineId} = this - const firstTileStartRow = this.getFirstTileStartRow() + const startRow = this.getRenderedStartRow() + const endRow = this.getRenderedEndRow() + // const firstTileStartRow = this.getFirstTileStartRow() const visibleTileCount = this.getVisibleTileCount() - const lastTileStartRow = this.getLastTileStartRow() + // const lastTileStartRow = this.getLastTileStartRow() + const rowsPerTile = this.getRowsPerTile() + const tileHeight = this.measurements.lineHeight * rowsPerTile + const tileWidth = this.getScrollWidth() const displayLayer = this.getModel().displayLayer - const screenLines = displayLayer.getScreenLines(firstTileStartRow, lastTileStartRow + this.getRowsPerTile()) + const screenLines = displayLayer.getScreenLines(startRow, endRow) - let tileNodes = new Array(visibleTileCount) - for (let tileStartRow = firstTileStartRow; tileStartRow <= lastTileStartRow; tileStartRow += this.getRowsPerTile()) { - const tileEndRow = tileStartRow + this.getRowsPerTile() - const lineNodes = [] - for (let row = tileStartRow; row < tileEndRow; row++) { - const screenLine = screenLines[row - firstTileStartRow] - if (!screenLine) break - lineNodes.push($(LineComponent, { - key: screenLine.id, - screenLine, - displayLayer, - lineNodesByScreenLineId, - textNodesByScreenLineId - })) - if (screenLine === this.longestLineToMeasure) { - this.longestLineToMeasure = null - } - } + const tileNodes = new Array(visibleTileCount) - const tileHeight = this.getRowsPerTile() * this.measurements.lineHeight - const tileIndex = (tileStartRow / this.getRowsPerTile()) % visibleTileCount + for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow += rowsPerTile) { + const tileEndRow = tileStartRow + rowsPerTile + const tileHeight = rowsPerTile * this.measurements.lineHeight + const tileIndex = (tileStartRow / rowsPerTile) % visibleTileCount - tileNodes[tileIndex] = $.div({ + tileNodes[tileIndex] = $(LinesTileComponent, { key: tileIndex, - style: { - contain: 'strict', - position: 'absolute', - height: tileHeight + 'px', - width: width, - willChange: 'transform', - transform: `translateY(${this.topPixelPositionForRow(tileStartRow)}px)`, - backgroundColor: 'inherit' - } - }, lineNodes) + height: tileHeight, + width: tileWidth, + top: this.topPixelPositionForRow(tileStartRow), + screenLines: screenLines.slice(tileStartRow - startRow, tileEndRow - startRow), + displayLayer, + lineNodesByScreenLineId, + textNodesByScreenLineId + }) } - if (this.longestLineToMeasure) { + if (this.longestLineToMeasure != null && (this.longestLineToMeasureRow < startRow || this.longestLineToMeasureRow >= endRow)) { tileNodes.push($(LineComponent, { key: this.longestLineToMeasure.id, screenLine: this.longestLineToMeasure, @@ -290,7 +282,6 @@ class TextEditorComponent { lineNodesByScreenLineId, textNodesByScreenLineId })) - this.longestLineToMeasure = null } return $.div({ @@ -889,13 +880,13 @@ class TextEditorComponent { return row * this.measurements.lineHeight } - getLongestScreenLine () { + getLongestScreenLineRow () { const model = this.getModel() // Ensure the spatial index is populated with rows that are currently // visible so we *at least* get the longest row in the visible range. const renderedEndRow = this.getTileStartRow(this.getLastVisibleRow()) + this.getRowsPerTile() model.displayLayer.populateSpatialIndexIfNeeded(Infinity, renderedEndRow) - return model.screenLineForScreenRow(model.getApproximateLongestScreenRow()) + return model.getApproximateLongestScreenRow() } getNextUpdatePromise () { @@ -1011,6 +1002,65 @@ class LineNumberGutterComponent { } } +class LinesTileComponent { + constructor (props) { + this.props = props + etch.initialize(this) + } + + update (newProps) { + if (this.shouldUpdate(newProps)) { + this.props = newProps + etch.updateSync(this) + } + } + + render () { + const { + height, width, top, + screenLines, displayLayer, + lineNodesByScreenLineId, textNodesByScreenLineId + } = this.props + + const children = new Array(screenLines.length) + for (let i = 0, length = screenLines.length; i < length; i++) { + const screenLine = screenLines[i] + if (!screenLine) { + children.length = i + break + } + children[i] = $(LineComponent, { + key: screenLine.id, + screenLine, + displayLayer, + lineNodesByScreenLineId, + textNodesByScreenLineId + }) + } + + return $.div({ + style: { + contain: 'strict', + position: 'absolute', + height: height + 'px', + width: width + 'px', + willChange: 'transform', + transform: `translateY(${top}px)`, + backgroundColor: 'inherit' + } + }, children) + } + + shouldUpdate (newProps) { + const oldProps = this.props + if (oldProps.top !== newProps.top) return true + if (oldProps.height !== newProps.height) return true + if (oldProps.width !== newProps.width) return true + if (!arraysEqual(oldProps.screenLines, newProps.screenLines)) return true + return false + } +} + class LineComponent { constructor (props) { const {displayLayer, screenLine, lineNodesByScreenLineId, textNodesByScreenLineId} = props From 7196b05af7676241acc136d183ea63dde93a5c59 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 1 Mar 2017 13:29:19 -0700 Subject: [PATCH 074/403] Cache line number gutter properties during a single frame These properties are somewhat expensive to compute. Since we need to perform 2 updates per frame to perform horizontal measurement, it's good to avoid computing the gutter properties twice since they aren't affected by horizontal measurements in any way. --- src/text-editor-component.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 3a9e1cf428f..8e00e5a1a87 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -107,6 +107,7 @@ class TextEditorComponent { etch.updateSync(this) this.pendingAutoscroll = null + this.currentFramelineNumberGutterProps = null } render () { @@ -158,6 +159,10 @@ class TextEditorComponent { } renderLineNumberGutter () { + if (this.currentFramelineNumberGutterProps) { + return $(LineNumberGutterComponent, this.currentFramelineNumberGutterProps) + } + const model = this.getModel() const maxLineNumberDigits = Math.max(2, model.getLineCount().toString().length) @@ -181,13 +186,15 @@ class TextEditorComponent { const rowsPerTile = this.getRowsPerTile() - return $(LineNumberGutterComponent, { + this.currentFramelineNumberGutterProps = { height: this.getScrollHeight(), width: this.measurements.lineNumberGutterWidth, lineHeight: this.measurements.lineHeight, startRow, endRow, rowsPerTile, maxLineNumberDigits, bufferRows, softWrappedFlags, foldableFlags - }) + } + + return $(LineNumberGutterComponent, this.currentFramelineNumberGutterProps) } else { return $.div( { From 375b4a00ecd9480b3b7766195a5743e1ff1c3bce Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 2 Mar 2017 10:18:42 -0700 Subject: [PATCH 075/403] :arrow_up: etch and text-buffer to support new rendering code --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 8073c4aef78..e1d4a4797c9 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "dedent": "^0.6.0", "devtron": "1.3.0", "element-resize-detector": "^1.1.10", + "etch": "^0.9.2", "event-kit": "^2.3.0", "find-parent-dir": "^0.3.0", "first-mate": "7.0.4", From ed537fd61a810041a5a279f1fb696a5e28518305 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 2 Mar 2017 10:45:14 -0700 Subject: [PATCH 076/403] Drop suppressLayerUpdateEvent flag We now emit marker layer update events synchronously at the end of transactions, so this isn't needed or supported by text-buffer. --- src/text-editor.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/text-editor.coffee b/src/text-editor.coffee index f66159a0119..737e33ed9b9 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -210,7 +210,7 @@ class TextEditor extends Model if @cursors.length is 0 and not suppressCursorCreation initialLine = Math.max(parseInt(initialLine) or 0, 0) initialColumn = Math.max(parseInt(initialColumn) or 0, 0) - @addCursorAtBufferPosition([initialLine, initialColumn], {suppressLayerUpdateEvent: true}) + @addCursorAtBufferPosition([initialLine, initialColumn]) @languageMode = new LanguageMode(this) From 51755a0f251b4cf045eafd78109b27351054fe8b Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 2 Mar 2017 10:45:53 -0700 Subject: [PATCH 077/403] Don't render more line numbers than exist --- src/text-editor-component.js | 44 +++++++++++++++++------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 8e00e5a1a87..9551ef9d346 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -62,6 +62,8 @@ class TextEditorComponent { } updateSync () { + const model = this.getModel() + this.updateScheduled = false if (this.nextUpdatePromise) { this.resolveNextUpdatePromise() @@ -74,8 +76,10 @@ class TextEditorComponent { this.scrollWidthOrHeightChanged = false } - const longestLineRow = this.getLongestScreenLineRow() - const longestLine = this.getModel().screenLineForScreenRow(longestLineRow) + this.populateVisibleRowRange() + + const longestLineRow = model.getApproximateLongestScreenRow() + const longestLine = model.screenLineForScreenRow(longestLineRow) let measureLongestLine = false if (longestLine !== this.previousLongestLine) { this.longestLineToMeasure = longestLine @@ -107,7 +111,7 @@ class TextEditorComponent { etch.updateSync(this) this.pendingAutoscroll = null - this.currentFramelineNumberGutterProps = null + this.currentFrameLineNumberGutterProps = null } render () { @@ -159,8 +163,8 @@ class TextEditorComponent { } renderLineNumberGutter () { - if (this.currentFramelineNumberGutterProps) { - return $(LineNumberGutterComponent, this.currentFramelineNumberGutterProps) + if (this.currentFrameLineNumberGutterProps) { + return $(LineNumberGutterComponent, this.currentFrameLineNumberGutterProps) } const model = this.getModel() @@ -168,8 +172,7 @@ class TextEditorComponent { if (this.measurements) { const startRow = this.getRenderedStartRow() - const endRow = this.getRenderedEndRow() - + const endRow = Math.min(model.getApproximateScreenLineCount(), this.getRenderedEndRow()) const bufferRows = new Array(endRow - startRow) const foldableFlags = new Array(endRow - startRow) const softWrappedFlags = new Array(endRow - startRow) @@ -186,7 +189,7 @@ class TextEditorComponent { const rowsPerTile = this.getRowsPerTile() - this.currentFramelineNumberGutterProps = { + this.currentFrameLineNumberGutterProps = { height: this.getScrollHeight(), width: this.measurements.lineNumberGutterWidth, lineHeight: this.measurements.lineHeight, @@ -194,7 +197,7 @@ class TextEditorComponent { bufferRows, softWrappedFlags, foldableFlags } - return $(LineNumberGutterComponent, this.currentFramelineNumberGutterProps) + return $(LineNumberGutterComponent, this.currentFrameLineNumberGutterProps) } else { return $.div( { @@ -783,8 +786,6 @@ class TextEditorComponent { pixelLeftForScreenRowAndColumn (row, column) { if (column === 0) return 0 const screenLine = this.getModel().displayLayer.getScreenLine(row) - - if (!this.horizontalPixelPositionsByScreenLineId.has(screenLine.id)) debugger return this.horizontalPixelPositionsByScreenLineId.get(screenLine.id).get(column) } @@ -883,17 +884,14 @@ class TextEditorComponent { ) } - topPixelPositionForRow (row) { - return row * this.measurements.lineHeight + // Ensure the spatial index is populated with rows that are currently + // visible so we *at least* get the longest row in the visible range. + populateVisibleRowRange () { + this.getModel().displayLayer.populateSpatialIndexIfNeeded(Infinity, this.getRenderedEndRow()) } - getLongestScreenLineRow () { - const model = this.getModel() - // Ensure the spatial index is populated with rows that are currently - // visible so we *at least* get the longest row in the visible range. - const renderedEndRow = this.getTileStartRow(this.getLastVisibleRow()) + this.getRowsPerTile() - model.displayLayer.populateSpatialIndexIfNeeded(Infinity, renderedEndRow) - return model.getApproximateLongestScreenRow() + topPixelPositionForRow (row) { + return row * this.measurements.lineHeight } getNextUpdatePromise () { @@ -925,15 +923,15 @@ class LineNumberGutterComponent { maxLineNumberDigits, bufferRows, softWrappedFlags, foldableFlags } = this.props - const visibleTileCount = (endRow - startRow) / rowsPerTile + const visibleTileCount = Math.ceil((endRow - startRow) / rowsPerTile) const children = new Array(visibleTileCount) const tileHeight = rowsPerTile * lineHeight + 'px' const tileWidth = width + 'px' let softWrapCount = 0 for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow += rowsPerTile) { - const tileChildren = new Array(rowsPerTile) - const tileEndRow = tileStartRow + rowsPerTile + const tileEndRow = Math.min(endRow, tileStartRow + rowsPerTile) + const tileChildren = new Array(tileEndRow - tileStartRow) for (let row = tileStartRow; row < tileEndRow; row++) { const i = row - startRow const bufferRow = bufferRows[i] From 2ef29dee8875b07c4adac5fd281515c730be830a Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 2 Mar 2017 10:51:51 -0700 Subject: [PATCH 078/403] Refactor TextEditorComponent.prototype.updateSync --- src/text-editor-component.js | 47 +++++++++++++++--------------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 9551ef9d346..acb91e4f467 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -62,8 +62,6 @@ class TextEditorComponent { } updateSync () { - const model = this.getModel() - this.updateScheduled = false if (this.nextUpdatePromise) { this.resolveNextUpdatePromise() @@ -71,39 +69,19 @@ class TextEditorComponent { this.resolveNextUpdatePromise = null } - if (this.scrollWidthOrHeightChanged) { - this.measureClientDimensions() - this.scrollWidthOrHeightChanged = false - } - + if (this.scrollWidthOrHeightChanged) this.measureClientDimensions() this.populateVisibleRowRange() - - const longestLineRow = model.getApproximateLongestScreenRow() - const longestLine = model.screenLineForScreenRow(longestLineRow) - let measureLongestLine = false - if (longestLine !== this.previousLongestLine) { - this.longestLineToMeasure = longestLine - this.longestLineToMeasureRow = longestLineRow - this.previousLongestLine = longestLine - measureLongestLine = true - } - - if (this.pendingAutoscroll) { - this.autoscrollVertically() - } - + const longestLineToMeasure = this.checkForNewLongestLine() + if (this.pendingAutoscroll) this.autoscrollVertically() this.horizontalPositionsToMeasure.clear() + etch.updateSync(this) if (this.autoscrollTop != null) { this.refs.scroller.scrollTop = this.autoscrollTop this.autoscrollTop = null } - if (measureLongestLine) { - this.measureLongestLineWidth(longestLine) - this.longestLineToMeasureRow = null - this.longestLineToMeasure = null - } + if (longestLineToMeasure) this.measureLongestLineWidth(longestLineToMeasure) this.queryCursorsToRender() this.measureHorizontalPositions() this.positionCursorsToRender() @@ -693,6 +671,7 @@ class TextEditorComponent { this.measurements.clientWidth = clientWidth clientDimensionsChanged = true } + this.scrollWidthOrHeightChanged = false return clientDimensionsChanged } @@ -704,8 +683,22 @@ class TextEditorComponent { this.measurements.koreanCharacterWidth = this.refs.koreanCharacterSpan.getBoundingClientRect().widt } + checkForNewLongestLine () { + const model = this.getModel() + const longestLineRow = model.getApproximateLongestScreenRow() + const longestLine = model.screenLineForScreenRow(longestLineRow) + if (longestLine !== this.previousLongestLine) { + this.longestLineToMeasure = longestLine + this.longestLineToMeasureRow = longestLineRow + this.previousLongestLine = longestLine + return longestLine + } + } + measureLongestLineWidth (screenLine) { this.measurements.longestLineWidth = this.lineNodesByScreenLineId.get(screenLine.id).firstChild.offsetWidth + this.longestLineToMeasureRow = null + this.longestLineToMeasure = null } measureGutterDimensions () { From 3e87f9f88932a89969584c673c4e85765278f5f5 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 2 Mar 2017 14:59:41 -0700 Subject: [PATCH 079/403] Add horizontal autoscroll --- spec/text-editor-component-spec.js | 51 +++++++++++++ src/text-editor-component.js | 119 +++++++++++++++++++++++------ src/text-editor.coffee | 1 + 3 files changed, 148 insertions(+), 23 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 9892a4c1e77..e6256a0bdb8 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -273,6 +273,57 @@ describe('TextEditorComponent', () => { await component.getNextUpdatePromise() expect(scroller.scrollTop).toBe((4 - scrollMarginInLines) * component.measurements.lineHeight) }) + + it('automatically scrolls horizontally when the cursor is within horizontal scroll margin of the right edge of the gutter or right edge of the screen', async () => { + const {component, element, editor} = buildComponent() + const {scroller} = component.refs + element.style.width = + component.getGutterContainerWidth() + + 3 * editor.horizontalScrollMargin * component.measurements.baseCharacterWidth + 'px' + await component.getNextUpdatePromise() + + editor.scrollToScreenRange([[1, 12], [2, 28]]) + await component.getNextUpdatePromise() + let expectedScrollLeft = Math.floor( + clientLeftForCharacter(component, 1, 12) - + lineNodeForScreenRow(component, 1).getBoundingClientRect().left - + (editor.horizontalScrollMargin * component.measurements.baseCharacterWidth) + ) + expect(scroller.scrollLeft).toBe(expectedScrollLeft) + + editor.scrollToScreenRange([[1, 12], [2, 28]], {reversed: false}) + await component.getNextUpdatePromise() + expectedScrollLeft = Math.floor( + component.getGutterContainerWidth() + + clientLeftForCharacter(component, 2, 28) - + lineNodeForScreenRow(component, 2).getBoundingClientRect().left + + (editor.horizontalScrollMargin * component.measurements.baseCharacterWidth) - + scroller.clientWidth + ) + expect(scroller.scrollLeft).toBe(expectedScrollLeft) + }) + + it('does not horizontally autoscroll by more than half of the visible "base-width" characters if the editor is narrower than twice the scroll margin', async () => { + const {component, element, editor} = buildComponent() + const {scroller, gutterContainer} = component.refs + element.style.width = + component.getGutterContainerWidth() + + 1.5 * editor.horizontalScrollMargin * component.measurements.baseCharacterWidth + 'px' + await component.getNextUpdatePromise() + + const contentWidth = scroller.clientWidth - gutterContainer.offsetWidth + const contentWidthInCharacters = Math.floor(contentWidth / component.measurements.baseCharacterWidth) + expect(contentWidthInCharacters).toBe(9) + + editor.scrollToScreenRange([[6, 10], [6, 15]]) + await component.getNextUpdatePromise() + let expectedScrollLeft = Math.floor( + clientLeftForCharacter(component, 6, 10) - + lineNodeForScreenRow(component, 1).getBoundingClientRect().left - + (4 * component.measurements.baseCharacterWidth) + ) + expect(scroller.scrollLeft).toBe(expectedScrollLeft) + }) }) }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index acb91e4f467..fbb17668425 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -31,7 +31,7 @@ class TextEditorComponent { this.textNodesByScreenLineId = new Map() this.pendingAutoscroll = null this.autoscrollTop = null - this.scrollWidthOrHeightChanged = false + this.contentWidthOrHeightChanged = false this.previousScrollWidth = 0 this.previousScrollHeight = 0 this.lastKeydown = null @@ -69,26 +69,22 @@ class TextEditorComponent { this.resolveNextUpdatePromise = null } - if (this.scrollWidthOrHeightChanged) this.measureClientDimensions() + this.horizontalPositionsToMeasure.clear() + if (this.contentWidthOrHeightChanged) this.measureClientDimensions() + if (this.pendingAutoscroll) this.initiateAutoscroll() this.populateVisibleRowRange() const longestLineToMeasure = this.checkForNewLongestLine() - if (this.pendingAutoscroll) this.autoscrollVertically() - this.horizontalPositionsToMeasure.clear() + this.queryCursorsToRender() etch.updateSync(this) - if (this.autoscrollTop != null) { - this.refs.scroller.scrollTop = this.autoscrollTop - this.autoscrollTop = null - } - if (longestLineToMeasure) this.measureLongestLineWidth(longestLineToMeasure) - this.queryCursorsToRender() this.measureHorizontalPositions() + if (longestLineToMeasure) this.measureLongestLineWidth(longestLineToMeasure) + if (this.pendingAutoscroll) this.finalizeAutoscroll() this.positionCursorsToRender() etch.updateSync(this) - this.pendingAutoscroll = null this.currentFrameLineNumberGutterProps = null } @@ -198,15 +194,15 @@ class TextEditorComponent { overflow: 'hidden' } if (this.measurements) { - const scrollWidth = this.getScrollWidth() + const contentWidth = this.getContentWidth() const scrollHeight = this.getScrollHeight() - if (scrollWidth !== this.previousScrollWidth || scrollHeight !== this.previousScrollHeight) { - this.scrollWidthOrHeightChanged = true - this.previousScrollWidth = scrollWidth + if (contentWidth !== this.previousScrollWidth || scrollHeight !== this.previousScrollHeight) { + this.contentWidthOrHeightChanged = true + this.previousScrollWidth = contentWidth this.previousScrollHeight = scrollHeight } - const width = scrollWidth + 'px' + const width = contentWidth + 'px' const height = scrollHeight + 'px' style.width = width style.height = height @@ -238,7 +234,7 @@ class TextEditorComponent { // const lastTileStartRow = this.getLastTileStartRow() const rowsPerTile = this.getRowsPerTile() const tileHeight = this.measurements.lineHeight * rowsPerTile - const tileWidth = this.getScrollWidth() + const tileWidth = this.getContentWidth() const displayLayer = this.getModel().displayLayer const screenLines = displayLayer.getScreenLines(startRow, endRow) @@ -565,13 +561,16 @@ class TextEditorComponent { this.scheduleUpdate() } - autoscrollVertically () { + initiateAutoscroll () { const {screenRange, options} = this.pendingAutoscroll const screenRangeTop = this.pixelTopForScreenRow(screenRange.start.row) const screenRangeBottom = this.pixelTopForScreenRow(screenRange.end.row) + this.measurements.lineHeight const verticalScrollMargin = this.getVerticalScrollMargin() + this.requestHorizontalMeasurement(screenRange.start.row, screenRange.start.column) + this.requestHorizontalMeasurement(screenRange.end.row, screenRange.end.column) + let desiredScrollTop, desiredScrollBottom if (options && options.center) { const desiredScrollCenter = (screenRangeTop + screenRangeBottom) / 2 @@ -595,22 +594,65 @@ class TextEditorComponent { if (!options || options.reversed !== false) { if (desiredScrollBottom > this.getScrollBottom()) { this.autoscrollTop = desiredScrollBottom - this.measurements.clientHeight + this.measurements.scrollTop = this.autoscrollTop } if (desiredScrollTop < this.getScrollTop()) { this.autoscrollTop = desiredScrollTop + this.measurements.scrollTop = this.autoscrollTop } } else { if (desiredScrollTop < this.getScrollTop()) { this.autoscrollTop = desiredScrollTop + this.measurements.scrollTop = this.autoscrollTop } if (desiredScrollBottom > this.getScrollBottom()) { this.autoscrollTop = desiredScrollBottom - this.measurements.clientHeight + this.measurements.scrollTop = this.autoscrollTop + } + } + } + + finalizeAutoscroll () { + const horizontalScrollMargin = this.getHorizontalScrollMargin() + + const {screenRange, options} = this.pendingAutoscroll + const gutterContainerWidth = this.getGutterContainerWidth() + let left = this.pixelLeftForScreenRowAndColumn(screenRange.start.row, screenRange.start.column) + gutterContainerWidth + let right = this.pixelLeftForScreenRowAndColumn(screenRange.end.row, screenRange.end.column) + gutterContainerWidth + const desiredScrollLeft = Math.max(0, left - horizontalScrollMargin - gutterContainerWidth) + const desiredScrollRight = Math.min(this.getScrollWidth(), right + horizontalScrollMargin) + + let autoscrollLeft + if (!options || options.reversed !== false) { + if (desiredScrollRight > this.getScrollRight()) { + autoscrollLeft = desiredScrollRight - this.getClientWidth() + this.measurements.scrollLeft = autoscrollLeft + } + if (desiredScrollLeft < this.getScrollLeft()) { + autoscrollLeft = desiredScrollLeft + this.measurements.scrollLeft = autoscrollLeft + } + } else { + if (desiredScrollLeft < this.getScrollLeft()) { + autoscrollLeft = desiredScrollLeft + this.measurements.scrollLeft = autoscrollLeft + } + if (desiredScrollRight > this.getScrollRight()) { + autoscrollLeft = desiredScrollRight - this.getClientWidth() + this.measurements.scrollLeft = autoscrollLeft } } if (this.autoscrollTop != null) { - this.measurements.scrollTop = this.autoscrollTop + this.refs.scroller.scrollTop = this.autoscrollTop + this.autoscrollTop = null + } + + if (autoscrollLeft != null) { + this.refs.scroller.scrollLeft = autoscrollLeft } + + this.pendingAutoscroll = null } getVerticalScrollMargin () { @@ -619,7 +661,17 @@ class TextEditorComponent { this.getModel().verticalScrollMargin, Math.floor(((clientHeight / lineHeight) - 1) / 2) ) - return marginInLines * this.measurements.lineHeight + return marginInLines * lineHeight + } + + getHorizontalScrollMargin () { + const {clientWidth, baseCharacterWidth} = this.measurements + const contentClientWidth = clientWidth - this.getGutterContainerWidth() + const marginInBaseCharacters = Math.min( + this.getModel().horizontalScrollMargin, + Math.floor(((contentClientWidth / baseCharacterWidth) - 1) / 2) + ) + return marginInBaseCharacters * baseCharacterWidth } performInitialMeasurements () { @@ -671,7 +723,7 @@ class TextEditorComponent { this.measurements.clientWidth = clientWidth clientDimensionsChanged = true } - this.scrollWidthOrHeightChanged = false + this.contentWidthOrHeightChanged = false return clientDimensionsChanged } @@ -743,6 +795,9 @@ class TextEditorComponent { if (nextColumnToMeasure === 0) { positions.set(0, 0) continue columnLoop + } + if (nextColumnToMeasure >= lineNode.textContent.length) { + } if (positions.has(nextColumnToMeasure)) continue columnLoop const textNode = textNodes[textNodesIndex] @@ -815,7 +870,7 @@ class TextEditorComponent { getScrollBottom () { return this.measurements - ? this.getScrollTop() + this.measurements.clientHeight + ? this.measurements.scrollTop + this.measurements.clientHeight : null } @@ -823,18 +878,36 @@ class TextEditorComponent { return this.measurements ? this.measurements.scrollLeft : null } + getScrollRight () { + return this.measurements + ? this.measurements.scrollLeft + this.measurements.clientWidth + : null + } + getScrollHeight () { return this.getModel().getApproximateScreenLineCount() * this.measurements.lineHeight } getScrollWidth () { - return Math.round(this.measurements.longestLineWidth + this.measurements.baseCharacterWidth) + return this.getContentWidth() + this.getGutterContainerWidth() } getClientHeight () { return this.measurements.clientHeight } + getClientWidth () { + return this.measurements.clientWidth + } + + getGutterContainerWidth () { + return this.measurements.lineNumberGutterWidth + } + + getContentWidth () { + return Math.round(this.measurements.longestLineWidth + this.measurements.baseCharacterWidth) + } + getRowsPerTile () { return this.props.rowsPerTile || DEFAULT_ROWS_PER_TILE } diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 737e33ed9b9..6fa67400da6 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -3426,6 +3426,7 @@ class TextEditor extends Model @getElement().scrollToBottom() scrollToScreenRange: (screenRange, options = {}) -> + screenRange = @clipScreenRange(screenRange) scrollEvent = {screenRange, options} @emitter.emit "did-request-autoscroll", scrollEvent From b9feddacbec9fc7461d4e50c683c88c6c156837e Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 2 Mar 2017 16:15:43 -0700 Subject: [PATCH 080/403] Fail focus tests quickly and clearly if document isn't focused --- spec/text-editor-component-spec.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index e6256a0bdb8..2d959f0a401 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -186,6 +186,8 @@ describe('TextEditorComponent', () => { describe('focus', () => { it('focuses the hidden input element and adds the is-focused class when focused', async () => { + assertDocumentFocused() + const {component, element, editor} = buildComponent() const {hiddenInput} = component.refs @@ -206,6 +208,8 @@ describe('TextEditorComponent', () => { }) it('updates the component when the hidden input is focused directly', async () => { + assertDocumentFocused() + const {component, element, editor} = buildComponent() const {hiddenInput} = component.refs expect(element.classList.contains('is-focused')).toBe(false) @@ -217,6 +221,8 @@ describe('TextEditorComponent', () => { }) it('gracefully handles a focus event that occurs prior to the attachedCallback of the element', () => { + assertDocumentFocused() + const {component, element, editor} = buildComponent({attach: false}) const parent = document.createElement('text-editor-component-test-element') parent.appendChild(element) @@ -361,3 +367,9 @@ function textNodesForScreenRow (component, row) { const screenLine = component.getModel().screenLineForScreenRow(row) return component.textNodesByScreenLineId.get(screenLine.id) } + +function assertDocumentFocused () { + if (!document.hasFocus()) { + throw new Error('The document needs to be focused to run this test') + } +} From 94294d1b92cf34ade6159e5520d8b85e2e6b6081 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 2 Mar 2017 16:36:15 -0700 Subject: [PATCH 081/403] Test autoscrolling via scrollToScreenPosition instead of cursor --- spec/text-editor-component-spec.js | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 2d959f0a401..75422ba6560 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -233,26 +233,26 @@ describe('TextEditorComponent', () => { }) describe('autoscroll', () => { - it('automatically scrolls vertically when the cursor is within vertical scroll margin of the top or bottom', async () => { + it('automatically scrolls vertically when the requested range is within the vertical scroll margin of the top or bottom', async () => { const {component, element, editor} = buildComponent({height: 120}) const {scroller} = component.refs expect(component.getLastVisibleRow()).toBe(8) - editor.setCursorScreenPosition([6, 0]) + editor.scrollToScreenRange([[4, 0], [6, 0]]) await component.getNextUpdatePromise() let scrollBottom = scroller.scrollTop + scroller.clientHeight expect(scrollBottom).toBe((6 + 1 + editor.verticalScrollMargin) * component.measurements.lineHeight) - editor.setCursorScreenPosition([8, 0]) + editor.scrollToScreenPosition([8, 0]) await component.getNextUpdatePromise() scrollBottom = scroller.scrollTop + scroller.clientHeight expect(scrollBottom).toBe((8 + 1 + editor.verticalScrollMargin) * component.measurements.lineHeight) - editor.setCursorScreenPosition([3, 0]) + editor.scrollToScreenPosition([3, 0]) await component.getNextUpdatePromise() expect(scroller.scrollTop).toBe((3 - editor.verticalScrollMargin) * component.measurements.lineHeight) - editor.setCursorScreenPosition([2, 0]) + editor.scrollToScreenPosition([2, 0]) await component.getNextUpdatePromise() expect(scroller.scrollTop).toBe(0) }) @@ -265,22 +265,26 @@ describe('TextEditorComponent', () => { expect(component.getLastVisibleRow()).toBe(6) const scrollMarginInLines = 2 - editor.setCursorScreenPosition([6, 0]) + editor.scrollToScreenPosition([6, 0]) await component.getNextUpdatePromise() let scrollBottom = scroller.scrollTop + scroller.clientHeight expect(scrollBottom).toBe((6 + 1 + scrollMarginInLines) * component.measurements.lineHeight) - editor.setCursorScreenPosition([6, 4]) + editor.scrollToScreenPosition([6, 4]) await component.getNextUpdatePromise() scrollBottom = scroller.scrollTop + scroller.clientHeight expect(scrollBottom).toBe((6 + 1 + scrollMarginInLines) * component.measurements.lineHeight) - editor.setCursorScreenPosition([4, 4]) + editor.scrollToScreenRange([[4, 4], [6, 4]]) await component.getNextUpdatePromise() expect(scroller.scrollTop).toBe((4 - scrollMarginInLines) * component.measurements.lineHeight) + + editor.scrollToScreenRange([[4, 4], [6, 4]], {reversed: false}) + await component.getNextUpdatePromise() + expect(scrollBottom).toBe((6 + 1 + scrollMarginInLines) * component.measurements.lineHeight) }) - it('automatically scrolls horizontally when the cursor is within horizontal scroll margin of the right edge of the gutter or right edge of the screen', async () => { + it('automatically scrolls horizontally when the requested range is within the horizontal scroll margin of the right edge of the gutter or right edge of the screen', async () => { const {component, element, editor} = buildComponent() const {scroller} = component.refs element.style.width = From 30cd83f7aa03c023e6103b648906107796472fe8 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 3 Mar 2017 05:23:14 -0700 Subject: [PATCH 082/403] Convert DecorationManager to JS --- spec/decoration-manager-spec.coffee | 3 +- src/decoration-manager.coffee | 191 ------------------- src/decoration-manager.js | 272 ++++++++++++++++++++++++++++ 3 files changed, 273 insertions(+), 193 deletions(-) delete mode 100644 src/decoration-manager.coffee create mode 100644 src/decoration-manager.js diff --git a/spec/decoration-manager-spec.coffee b/spec/decoration-manager-spec.coffee index e57660a57b2..ba5de0cf230 100644 --- a/spec/decoration-manager-spec.coffee +++ b/spec/decoration-manager-spec.coffee @@ -14,8 +14,7 @@ describe "DecorationManager", -> atom.packages.activatePackage('language-javascript') afterEach -> - decorationManager.destroy() - buffer.release() + buffer.destroy() describe "decorations", -> [layer1Marker, layer2Marker, layer1MarkerDecoration, layer2MarkerDecoration, decorationProperties] = [] diff --git a/src/decoration-manager.coffee b/src/decoration-manager.coffee deleted file mode 100644 index 05935f01851..00000000000 --- a/src/decoration-manager.coffee +++ /dev/null @@ -1,191 +0,0 @@ -{Emitter} = require 'event-kit' -Model = require './model' -Decoration = require './decoration' -LayerDecoration = require './layer-decoration' - -module.exports = -class DecorationManager extends Model - didUpdateDecorationsEventScheduled: false - updatedSynchronously: false - - constructor: (@displayLayer) -> - super - - @emitter = new Emitter - @decorationsById = {} - @decorationsByMarkerId = {} - @overlayDecorationsById = {} - @layerDecorationsByMarkerLayerId = {} - @decorationCountsByLayerId = {} - @layerUpdateDisposablesByLayerId = {} - - observeDecorations: (callback) -> - callback(decoration) for decoration in @getDecorations() - @onDidAddDecoration(callback) - - onDidAddDecoration: (callback) -> - @emitter.on 'did-add-decoration', callback - - onDidRemoveDecoration: (callback) -> - @emitter.on 'did-remove-decoration', callback - - onDidUpdateDecorations: (callback) -> - @emitter.on 'did-update-decorations', callback - - setUpdatedSynchronously: (@updatedSynchronously) -> - - decorationForId: (id) -> - @decorationsById[id] - - getDecorations: (propertyFilter) -> - allDecorations = [] - for markerId, decorations of @decorationsByMarkerId - allDecorations.push(decorations...) if decorations? - if propertyFilter? - allDecorations = allDecorations.filter (decoration) -> - for key, value of propertyFilter - return false unless decoration.properties[key] is value - true - allDecorations - - getLineDecorations: (propertyFilter) -> - @getDecorations(propertyFilter).filter (decoration) -> decoration.isType('line') - - getLineNumberDecorations: (propertyFilter) -> - @getDecorations(propertyFilter).filter (decoration) -> decoration.isType('line-number') - - getHighlightDecorations: (propertyFilter) -> - @getDecorations(propertyFilter).filter (decoration) -> decoration.isType('highlight') - - getOverlayDecorations: (propertyFilter) -> - result = [] - for id, decoration of @overlayDecorationsById - result.push(decoration) - if propertyFilter? - result.filter (decoration) -> - for key, value of propertyFilter - return false unless decoration.properties[key] is value - true - else - result - - decorationsForScreenRowRange: (startScreenRow, endScreenRow) -> - decorationsByMarkerId = {} - for layerId of @decorationCountsByLayerId - layer = @displayLayer.getMarkerLayer(layerId) - for marker in layer.findMarkers(intersectsScreenRowRange: [startScreenRow, endScreenRow]) - if decorations = @decorationsByMarkerId[marker.id] - decorationsByMarkerId[marker.id] = decorations - decorationsByMarkerId - - decorationsStateForScreenRowRange: (startScreenRow, endScreenRow) -> - decorationsState = {} - - for layerId of @decorationCountsByLayerId - layer = @displayLayer.getMarkerLayer(layerId) - - for marker in layer.findMarkers(intersectsScreenRowRange: [startScreenRow, endScreenRow]) when marker.isValid() - screenRange = marker.getScreenRange() - bufferRange = marker.getBufferRange() - rangeIsReversed = marker.isReversed() - - if decorations = @decorationsByMarkerId[marker.id] - for decoration in decorations - decorationsState[decoration.id] = { - properties: decoration.properties - screenRange, bufferRange, rangeIsReversed - } - - if layerDecorations = @layerDecorationsByMarkerLayerId[layerId] - for layerDecoration in layerDecorations - decorationsState["#{layerDecoration.id}-#{marker.id}"] = { - properties: layerDecoration.overridePropertiesByMarkerId[marker.id] ? layerDecoration.properties - screenRange, bufferRange, rangeIsReversed - } - - decorationsState - - decorateMarker: (marker, decorationParams) -> - if marker.isDestroyed() - error = new Error("Cannot decorate a destroyed marker") - error.metadata = {markerLayerIsDestroyed: marker.layer.isDestroyed()} - if marker.destroyStackTrace? - error.metadata.destroyStackTrace = marker.destroyStackTrace - if marker.bufferMarker?.destroyStackTrace? - error.metadata.destroyStackTrace = marker.bufferMarker?.destroyStackTrace - throw error - marker = @displayLayer.getMarkerLayer(marker.layer.id).getMarker(marker.id) - decoration = new Decoration(marker, this, decorationParams) - @decorationsByMarkerId[marker.id] ?= [] - @decorationsByMarkerId[marker.id].push(decoration) - @overlayDecorationsById[decoration.id] = decoration if decoration.isType('overlay') - @decorationsById[decoration.id] = decoration - @observeDecoratedLayer(marker.layer) - @scheduleUpdateDecorationsEvent() - @emitter.emit 'did-add-decoration', decoration - decoration - - decorateMarkerLayer: (markerLayer, decorationParams) -> - throw new Error("Cannot decorate a destroyed marker layer") if markerLayer.isDestroyed() - decoration = new LayerDecoration(markerLayer, this, decorationParams) - @layerDecorationsByMarkerLayerId[markerLayer.id] ?= [] - @layerDecorationsByMarkerLayerId[markerLayer.id].push(decoration) - @observeDecoratedLayer(markerLayer) - @scheduleUpdateDecorationsEvent() - decoration - - decorationsForMarkerId: (markerId) -> - @decorationsByMarkerId[markerId] - - scheduleUpdateDecorationsEvent: -> - if @updatedSynchronously - @emitter.emit 'did-update-decorations' - return - - unless @didUpdateDecorationsEventScheduled - @didUpdateDecorationsEventScheduled = true - process.nextTick => - @didUpdateDecorationsEventScheduled = false - @emitter.emit 'did-update-decorations' - - decorationDidChangeType: (decoration) -> - if decoration.isType('overlay') - @overlayDecorationsById[decoration.id] = decoration - else - delete @overlayDecorationsById[decoration.id] - - didDestroyMarkerDecoration: (decoration) -> - {marker} = decoration - return unless decorations = @decorationsByMarkerId[marker.id] - index = decorations.indexOf(decoration) - - if index > -1 - decorations.splice(index, 1) - delete @decorationsById[decoration.id] - @emitter.emit 'did-remove-decoration', decoration - delete @decorationsByMarkerId[marker.id] if decorations.length is 0 - delete @overlayDecorationsById[decoration.id] - @unobserveDecoratedLayer(marker.layer) - @scheduleUpdateDecorationsEvent() - - didDestroyLayerDecoration: (decoration) -> - {markerLayer} = decoration - return unless decorations = @layerDecorationsByMarkerLayerId[markerLayer.id] - index = decorations.indexOf(decoration) - - if index > -1 - decorations.splice(index, 1) - delete @layerDecorationsByMarkerLayerId[markerLayer.id] if decorations.length is 0 - @unobserveDecoratedLayer(markerLayer) - @scheduleUpdateDecorationsEvent() - - observeDecoratedLayer: (layer) -> - @decorationCountsByLayerId[layer.id] ?= 0 - if ++@decorationCountsByLayerId[layer.id] is 1 - @layerUpdateDisposablesByLayerId[layer.id] = layer.onDidUpdate(@scheduleUpdateDecorationsEvent.bind(this)) - - unobserveDecoratedLayer: (layer) -> - if --@decorationCountsByLayerId[layer.id] is 0 - @layerUpdateDisposablesByLayerId[layer.id].dispose() - delete @decorationCountsByLayerId[layer.id] - delete @layerUpdateDisposablesByLayerId[layer.id] diff --git a/src/decoration-manager.js b/src/decoration-manager.js new file mode 100644 index 00000000000..f9e31e60d6b --- /dev/null +++ b/src/decoration-manager.js @@ -0,0 +1,272 @@ +const {Emitter} = require('event-kit') +const Decoration = require('./decoration') +const LayerDecoration = require('./layer-decoration') + +module.exports = +class DecorationManager { + constructor(displayLayer) { + this.displayLayer = displayLayer + + this.emitter = new Emitter + this.didUpdateDecorationsEventScheduled = false + this.updatedSynchronously = false + this.decorationsById = {} + this.decorationsByMarkerId = {} + this.overlayDecorationsById = {} + this.layerDecorationsByMarkerLayerId = {} + this.decorationCountsByLayerId = {} + this.layerUpdateDisposablesByLayerId = {} + } + + observeDecorations(callback) { + for (let decoration of this.getDecorations()) { callback(decoration); } + return this.onDidAddDecoration(callback) + } + + onDidAddDecoration(callback) { + return this.emitter.on('did-add-decoration', callback) + } + + onDidRemoveDecoration(callback) { + return this.emitter.on('did-remove-decoration', callback) + } + + onDidUpdateDecorations(callback) { + return this.emitter.on('did-update-decorations', callback) + } + + setUpdatedSynchronously(updatedSynchronously) { + this.updatedSynchronously = updatedSynchronously + } + + decorationForId(id) { + return this.decorationsById[id] + } + + getDecorations(propertyFilter) { + let allDecorations = [] + for (let markerId in this.decorationsByMarkerId) { + const decorations = this.decorationsByMarkerId[markerId] + if (decorations != null) { + allDecorations.push(...decorations) + } + } + if (propertyFilter != null) { + allDecorations = allDecorations.filter(function(decoration) { + for (let key in propertyFilter) { + const value = propertyFilter[key] + if (decoration.properties[key] !== value) return false + } + return true + }) + } + return allDecorations + } + + getLineDecorations(propertyFilter) { + return this.getDecorations(propertyFilter).filter(decoration => decoration.isType('line')) + } + + getLineNumberDecorations(propertyFilter) { + return this.getDecorations(propertyFilter).filter(decoration => decoration.isType('line-number')) + } + + getHighlightDecorations(propertyFilter) { + return this.getDecorations(propertyFilter).filter(decoration => decoration.isType('highlight')) + } + + getOverlayDecorations(propertyFilter) { + const result = [] + for (let id in this.overlayDecorationsById) { + const decoration = this.overlayDecorationsById[id] + result.push(decoration) + } + if (propertyFilter != null) { + return result.filter(function(decoration) { + for (let key in propertyFilter) { + const value = propertyFilter[key] + if (decoration.properties[key] !== value) { + return false + } + } + return true + }) + } else { + return result + } + } + + decorationsForScreenRowRange(startScreenRow, endScreenRow) { + const decorationsByMarkerId = {} + for (let layerId in this.decorationCountsByLayerId) { + const layer = this.displayLayer.getMarkerLayer(layerId) + for (let marker of layer.findMarkers({intersectsScreenRowRange: [startScreenRow, endScreenRow]})) { + const decorations = this.decorationsByMarkerId[marker.id] + if (decorations) { + decorationsByMarkerId[marker.id] = decorations + } + } + } + return decorationsByMarkerId + } + + decorationsStateForScreenRowRange(startScreenRow, endScreenRow) { + const decorationsState = {} + + for (let layerId in this.decorationCountsByLayerId) { + const layer = this.displayLayer.getMarkerLayer(layerId) + + for (let marker of layer.findMarkers({intersectsScreenRowRange: [startScreenRow, endScreenRow]})) { + if (marker.isValid()) { + const screenRange = marker.getScreenRange() + const bufferRange = marker.getBufferRange() + const rangeIsReversed = marker.isReversed() + + const decorations = this.decorationsByMarkerId[marker.id] + if (decorations) { + for (let decoration of decorations) { + decorationsState[decoration.id] = { + properties: decoration.properties, + screenRange, bufferRange, rangeIsReversed + } + } + } + + const layerDecorations = this.layerDecorationsByMarkerLayerId[layerId] + if (layerDecorations) { + for (let layerDecoration of layerDecorations) { + decorationsState[`${layerDecoration.id}-${marker.id}`] = { + properties: layerDecoration.overridePropertiesByMarkerId[marker.id] != null ? layerDecoration.overridePropertiesByMarkerId[marker.id] : layerDecoration.properties, + screenRange, bufferRange, rangeIsReversed + } + } + } + } + } + } + + return decorationsState + } + + decorateMarker(marker, decorationParams) { + if (marker.isDestroyed()) { + const error = new Error("Cannot decorate a destroyed marker") + error.metadata = {markerLayerIsDestroyed: marker.layer.isDestroyed()} + if (marker.destroyStackTrace != null) { + error.metadata.destroyStackTrace = marker.destroyStackTrace + } + if (marker.bufferMarker != null && marker.bufferMarker.destroyStackTrace != null) { + error.metadata.destroyStackTrace = marker.bufferMarker.destroyStackTrace + } + throw error + } + marker = this.displayLayer.getMarkerLayer(marker.layer.id).getMarker(marker.id) + const decoration = new Decoration(marker, this, decorationParams) + if (this.decorationsByMarkerId[marker.id] == null) { + this.decorationsByMarkerId[marker.id] = [] + } + this.decorationsByMarkerId[marker.id].push(decoration) + if (decoration.isType('overlay')) { + this.overlayDecorationsById[decoration.id] = decoration + } + this.decorationsById[decoration.id] = decoration + this.observeDecoratedLayer(marker.layer) + this.scheduleUpdateDecorationsEvent() + this.emitter.emit('did-add-decoration', decoration) + return decoration + } + + decorateMarkerLayer(markerLayer, decorationParams) { + if (markerLayer.isDestroyed()) { + throw new Error("Cannot decorate a destroyed marker layer") + } + const decoration = new LayerDecoration(markerLayer, this, decorationParams) + if (this.layerDecorationsByMarkerLayerId[markerLayer.id] == null) { + this.layerDecorationsByMarkerLayerId[markerLayer.id] = [] + } + this.layerDecorationsByMarkerLayerId[markerLayer.id].push(decoration) + this.observeDecoratedLayer(markerLayer) + this.scheduleUpdateDecorationsEvent() + return decoration + } + + decorationsForMarkerId(markerId) { + return this.decorationsByMarkerId[markerId] + } + + scheduleUpdateDecorationsEvent() { + if (this.updatedSynchronously) { + this.emitter.emit('did-update-decorations') + return + } + + if (!this.didUpdateDecorationsEventScheduled) { + this.didUpdateDecorationsEventScheduled = true + return process.nextTick(() => { + this.didUpdateDecorationsEventScheduled = false + return this.emitter.emit('did-update-decorations') + } + ) + } + } + + decorationDidChangeType(decoration) { + if (decoration.isType('overlay')) { + return this.overlayDecorationsById[decoration.id] = decoration + } else { + return delete this.overlayDecorationsById[decoration.id] + } + } + + didDestroyMarkerDecoration(decoration) { + let decorations + const {marker} = decoration + if (!(decorations = this.decorationsByMarkerId[marker.id])) return + const index = decorations.indexOf(decoration) + + if (index > -1) { + decorations.splice(index, 1) + delete this.decorationsById[decoration.id] + this.emitter.emit('did-remove-decoration', decoration) + if (decorations.length === 0) { + delete this.decorationsByMarkerId[marker.id] + } + delete this.overlayDecorationsById[decoration.id] + this.unobserveDecoratedLayer(marker.layer) + } + return this.scheduleUpdateDecorationsEvent() + } + + didDestroyLayerDecoration(decoration) { + let decorations + const {markerLayer} = decoration + if (!(decorations = this.layerDecorationsByMarkerLayerId[markerLayer.id])) return + const index = decorations.indexOf(decoration) + + if (index > -1) { + decorations.splice(index, 1) + if (decorations.length === 0) { + delete this.layerDecorationsByMarkerLayerId[markerLayer.id] + } + this.unobserveDecoratedLayer(markerLayer) + } + return this.scheduleUpdateDecorationsEvent() + } + + observeDecoratedLayer(layer) { + if (this.decorationCountsByLayerId[layer.id] == null) { + this.decorationCountsByLayerId[layer.id] = 0 + } + if (++this.decorationCountsByLayerId[layer.id] === 1) { + this.layerUpdateDisposablesByLayerId[layer.id] = layer.onDidUpdate(this.scheduleUpdateDecorationsEvent.bind(this)) + } + } + + unobserveDecoratedLayer(layer) { + if (--this.decorationCountsByLayerId[layer.id] === 0) { + this.layerUpdateDisposablesByLayerId[layer.id].dispose() + delete this.decorationCountsByLayerId[layer.id] + delete this.layerUpdateDisposablesByLayerId[layer.id] + } + } +} From b713210b0c4661a3964ecc7cc18933dcc1579884 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 3 Mar 2017 05:28:21 -0700 Subject: [PATCH 083/403] Emit didUpdateDecorations events synchronously The rendering layer can be asynchronous instead, plus layer decorations should remove the need to emit lots of individual events. --- src/decoration-manager.js | 32 +++++++------------------------- src/decoration.coffee | 4 ++-- 2 files changed, 9 insertions(+), 27 deletions(-) diff --git a/src/decoration-manager.js b/src/decoration-manager.js index f9e31e60d6b..dbaa68c8b6d 100644 --- a/src/decoration-manager.js +++ b/src/decoration-manager.js @@ -8,8 +8,6 @@ class DecorationManager { this.displayLayer = displayLayer this.emitter = new Emitter - this.didUpdateDecorationsEventScheduled = false - this.updatedSynchronously = false this.decorationsById = {} this.decorationsByMarkerId = {} this.overlayDecorationsById = {} @@ -35,10 +33,6 @@ class DecorationManager { return this.emitter.on('did-update-decorations', callback) } - setUpdatedSynchronously(updatedSynchronously) { - this.updatedSynchronously = updatedSynchronously - } - decorationForId(id) { return this.decorationsById[id] } @@ -171,7 +165,7 @@ class DecorationManager { } this.decorationsById[decoration.id] = decoration this.observeDecoratedLayer(marker.layer) - this.scheduleUpdateDecorationsEvent() + this.emitDidUpdateDecorations() this.emitter.emit('did-add-decoration', decoration) return decoration } @@ -186,7 +180,7 @@ class DecorationManager { } this.layerDecorationsByMarkerLayerId[markerLayer.id].push(decoration) this.observeDecoratedLayer(markerLayer) - this.scheduleUpdateDecorationsEvent() + this.emitDidUpdateDecorations() return decoration } @@ -194,20 +188,8 @@ class DecorationManager { return this.decorationsByMarkerId[markerId] } - scheduleUpdateDecorationsEvent() { - if (this.updatedSynchronously) { - this.emitter.emit('did-update-decorations') - return - } - - if (!this.didUpdateDecorationsEventScheduled) { - this.didUpdateDecorationsEventScheduled = true - return process.nextTick(() => { - this.didUpdateDecorationsEventScheduled = false - return this.emitter.emit('did-update-decorations') - } - ) - } + emitDidUpdateDecorations() { + this.emitter.emit('did-update-decorations') } decorationDidChangeType(decoration) { @@ -234,7 +216,7 @@ class DecorationManager { delete this.overlayDecorationsById[decoration.id] this.unobserveDecoratedLayer(marker.layer) } - return this.scheduleUpdateDecorationsEvent() + return this.emitDidUpdateDecorations() } didDestroyLayerDecoration(decoration) { @@ -250,7 +232,7 @@ class DecorationManager { } this.unobserveDecoratedLayer(markerLayer) } - return this.scheduleUpdateDecorationsEvent() + return this.emitDidUpdateDecorations() } observeDecoratedLayer(layer) { @@ -258,7 +240,7 @@ class DecorationManager { this.decorationCountsByLayerId[layer.id] = 0 } if (++this.decorationCountsByLayerId[layer.id] === 1) { - this.layerUpdateDisposablesByLayerId[layer.id] = layer.onDidUpdate(this.scheduleUpdateDecorationsEvent.bind(this)) + this.layerUpdateDisposablesByLayerId[layer.id] = layer.onDidUpdate(this.emitDidUpdateDecorations.bind(this)) } } diff --git a/src/decoration.coffee b/src/decoration.coffee index 19d029f76e9..5d406e17c53 100644 --- a/src/decoration.coffee +++ b/src/decoration.coffee @@ -150,7 +150,7 @@ class Decoration @properties = translateDecorationParamsOldToNew(newProperties) if newProperties.type? @decorationManager.decorationDidChangeType(this) - @decorationManager.scheduleUpdateDecorationsEvent() + @decorationManager.emitDidUpdateDecorations() @emitter.emit 'did-change-properties', {oldProperties, newProperties} ### @@ -175,5 +175,5 @@ class Decoration @properties.flashCount++ @properties.flashClass = klass @properties.flashDuration = duration - @decorationManager.scheduleUpdateDecorationsEvent() + @decorationManager.emitDidUpdateDecorations() @emitter.emit 'did-flash' From f8a0058f06ab1fec3e4bfd01db7d151563eec093 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 3 Mar 2017 05:31:16 -0700 Subject: [PATCH 084/403] Convert DecorationManager to standard style and remove unused method --- src/decoration-manager.js | 69 +++++++++++++++++++++------------------ src/text-editor.coffee | 3 -- 2 files changed, 37 insertions(+), 35 deletions(-) diff --git a/src/decoration-manager.js b/src/decoration-manager.js index dbaa68c8b6d..95e49acf2d9 100644 --- a/src/decoration-manager.js +++ b/src/decoration-manager.js @@ -4,10 +4,10 @@ const LayerDecoration = require('./layer-decoration') module.exports = class DecorationManager { - constructor(displayLayer) { + constructor (displayLayer) { this.displayLayer = displayLayer - this.emitter = new Emitter + this.emitter = new Emitter() this.decorationsById = {} this.decorationsByMarkerId = {} this.overlayDecorationsById = {} @@ -16,28 +16,28 @@ class DecorationManager { this.layerUpdateDisposablesByLayerId = {} } - observeDecorations(callback) { - for (let decoration of this.getDecorations()) { callback(decoration); } + observeDecorations (callback) { + for (let decoration of this.getDecorations()) { callback(decoration) } return this.onDidAddDecoration(callback) } - onDidAddDecoration(callback) { + onDidAddDecoration (callback) { return this.emitter.on('did-add-decoration', callback) } - onDidRemoveDecoration(callback) { + onDidRemoveDecoration (callback) { return this.emitter.on('did-remove-decoration', callback) } - onDidUpdateDecorations(callback) { + onDidUpdateDecorations (callback) { return this.emitter.on('did-update-decorations', callback) } - decorationForId(id) { + decorationForId (id) { return this.decorationsById[id] } - getDecorations(propertyFilter) { + getDecorations (propertyFilter) { let allDecorations = [] for (let markerId in this.decorationsByMarkerId) { const decorations = this.decorationsByMarkerId[markerId] @@ -46,7 +46,7 @@ class DecorationManager { } } if (propertyFilter != null) { - allDecorations = allDecorations.filter(function(decoration) { + allDecorations = allDecorations.filter(function (decoration) { for (let key in propertyFilter) { const value = propertyFilter[key] if (decoration.properties[key] !== value) return false @@ -57,26 +57,26 @@ class DecorationManager { return allDecorations } - getLineDecorations(propertyFilter) { + getLineDecorations (propertyFilter) { return this.getDecorations(propertyFilter).filter(decoration => decoration.isType('line')) } - getLineNumberDecorations(propertyFilter) { + getLineNumberDecorations (propertyFilter) { return this.getDecorations(propertyFilter).filter(decoration => decoration.isType('line-number')) } - getHighlightDecorations(propertyFilter) { + getHighlightDecorations (propertyFilter) { return this.getDecorations(propertyFilter).filter(decoration => decoration.isType('highlight')) } - getOverlayDecorations(propertyFilter) { + getOverlayDecorations (propertyFilter) { const result = [] for (let id in this.overlayDecorationsById) { const decoration = this.overlayDecorationsById[id] result.push(decoration) } if (propertyFilter != null) { - return result.filter(function(decoration) { + return result.filter(function (decoration) { for (let key in propertyFilter) { const value = propertyFilter[key] if (decoration.properties[key] !== value) { @@ -90,7 +90,7 @@ class DecorationManager { } } - decorationsForScreenRowRange(startScreenRow, endScreenRow) { + decorationsForScreenRowRange (startScreenRow, endScreenRow) { const decorationsByMarkerId = {} for (let layerId in this.decorationCountsByLayerId) { const layer = this.displayLayer.getMarkerLayer(layerId) @@ -104,7 +104,7 @@ class DecorationManager { return decorationsByMarkerId } - decorationsStateForScreenRowRange(startScreenRow, endScreenRow) { + decorationsStateForScreenRowRange (startScreenRow, endScreenRow) { const decorationsState = {} for (let layerId in this.decorationCountsByLayerId) { @@ -121,7 +121,9 @@ class DecorationManager { for (let decoration of decorations) { decorationsState[decoration.id] = { properties: decoration.properties, - screenRange, bufferRange, rangeIsReversed + screenRange, + bufferRange, + rangeIsReversed } } } @@ -129,9 +131,12 @@ class DecorationManager { const layerDecorations = this.layerDecorationsByMarkerLayerId[layerId] if (layerDecorations) { for (let layerDecoration of layerDecorations) { + const properties = layerDecoration.overridePropertiesByMarkerId[marker.id] != null ? layerDecoration.overridePropertiesByMarkerId[marker.id] : layerDecoration.properties decorationsState[`${layerDecoration.id}-${marker.id}`] = { - properties: layerDecoration.overridePropertiesByMarkerId[marker.id] != null ? layerDecoration.overridePropertiesByMarkerId[marker.id] : layerDecoration.properties, - screenRange, bufferRange, rangeIsReversed + properties, + screenRange, + bufferRange, + rangeIsReversed } } } @@ -142,9 +147,9 @@ class DecorationManager { return decorationsState } - decorateMarker(marker, decorationParams) { + decorateMarker (marker, decorationParams) { if (marker.isDestroyed()) { - const error = new Error("Cannot decorate a destroyed marker") + const error = new Error('Cannot decorate a destroyed marker') error.metadata = {markerLayerIsDestroyed: marker.layer.isDestroyed()} if (marker.destroyStackTrace != null) { error.metadata.destroyStackTrace = marker.destroyStackTrace @@ -170,9 +175,9 @@ class DecorationManager { return decoration } - decorateMarkerLayer(markerLayer, decorationParams) { + decorateMarkerLayer (markerLayer, decorationParams) { if (markerLayer.isDestroyed()) { - throw new Error("Cannot decorate a destroyed marker layer") + throw new Error('Cannot decorate a destroyed marker layer') } const decoration = new LayerDecoration(markerLayer, this, decorationParams) if (this.layerDecorationsByMarkerLayerId[markerLayer.id] == null) { @@ -184,23 +189,23 @@ class DecorationManager { return decoration } - decorationsForMarkerId(markerId) { + decorationsForMarkerId (markerId) { return this.decorationsByMarkerId[markerId] } - emitDidUpdateDecorations() { + emitDidUpdateDecorations () { this.emitter.emit('did-update-decorations') } - decorationDidChangeType(decoration) { + decorationDidChangeType (decoration) { if (decoration.isType('overlay')) { - return this.overlayDecorationsById[decoration.id] = decoration + return (this.overlayDecorationsById[decoration.id] = decoration) } else { return delete this.overlayDecorationsById[decoration.id] } } - didDestroyMarkerDecoration(decoration) { + didDestroyMarkerDecoration (decoration) { let decorations const {marker} = decoration if (!(decorations = this.decorationsByMarkerId[marker.id])) return @@ -219,7 +224,7 @@ class DecorationManager { return this.emitDidUpdateDecorations() } - didDestroyLayerDecoration(decoration) { + didDestroyLayerDecoration (decoration) { let decorations const {markerLayer} = decoration if (!(decorations = this.layerDecorationsByMarkerLayerId[markerLayer.id])) return @@ -235,7 +240,7 @@ class DecorationManager { return this.emitDidUpdateDecorations() } - observeDecoratedLayer(layer) { + observeDecoratedLayer (layer) { if (this.decorationCountsByLayerId[layer.id] == null) { this.decorationCountsByLayerId[layer.id] = 0 } @@ -244,7 +249,7 @@ class DecorationManager { } } - unobserveDecoratedLayer(layer) { + unobserveDecoratedLayer (layer) { if (--this.decorationCountsByLayerId[layer.id] === 0) { this.layerUpdateDisposablesByLayerId[layer.id].dispose() delete this.decorationCountsByLayerId[layer.id] diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 6fa67400da6..aa0b0518670 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -749,9 +749,6 @@ class TextEditor extends Model isMini: -> @mini - setUpdatedSynchronously: (updatedSynchronously) -> - @decorationManager.setUpdatedSynchronously(updatedSynchronously) - onDidChangeMini: (callback) -> @emitter.on 'did-change-mini', callback From f4264719103713f9f208cc970faafe3e603d1b79 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 3 Mar 2017 05:41:16 -0700 Subject: [PATCH 085/403] Replace decorationsByMarkerId with map keyed by decoration --- src/decoration-manager.js | 34 +++++++++++++++------------------- src/text-editor.coffee | 3 --- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/src/decoration-manager.js b/src/decoration-manager.js index 95e49acf2d9..587df48ab6c 100644 --- a/src/decoration-manager.js +++ b/src/decoration-manager.js @@ -9,7 +9,7 @@ class DecorationManager { this.emitter = new Emitter() this.decorationsById = {} - this.decorationsByMarkerId = {} + this.decorationsByMarker = new Map() this.overlayDecorationsById = {} this.layerDecorationsByMarkerLayerId = {} this.decorationCountsByLayerId = {} @@ -39,12 +39,10 @@ class DecorationManager { getDecorations (propertyFilter) { let allDecorations = [] - for (let markerId in this.decorationsByMarkerId) { - const decorations = this.decorationsByMarkerId[markerId] - if (decorations != null) { - allDecorations.push(...decorations) - } - } + + this.decorationsByMarker.forEach((decorations, marker) => { + if (decorations != null) allDecorations.push(...decorations) + }) if (propertyFilter != null) { allDecorations = allDecorations.filter(function (decoration) { for (let key in propertyFilter) { @@ -95,7 +93,7 @@ class DecorationManager { for (let layerId in this.decorationCountsByLayerId) { const layer = this.displayLayer.getMarkerLayer(layerId) for (let marker of layer.findMarkers({intersectsScreenRowRange: [startScreenRow, endScreenRow]})) { - const decorations = this.decorationsByMarkerId[marker.id] + const decorations = this.decorationsByMarker.get(marker) if (decorations) { decorationsByMarkerId[marker.id] = decorations } @@ -116,7 +114,7 @@ class DecorationManager { const bufferRange = marker.getBufferRange() const rangeIsReversed = marker.isReversed() - const decorations = this.decorationsByMarkerId[marker.id] + const decorations = this.decorationsByMarker.get(marker.id) if (decorations) { for (let decoration of decorations) { decorationsState[decoration.id] = { @@ -161,10 +159,12 @@ class DecorationManager { } marker = this.displayLayer.getMarkerLayer(marker.layer.id).getMarker(marker.id) const decoration = new Decoration(marker, this, decorationParams) - if (this.decorationsByMarkerId[marker.id] == null) { - this.decorationsByMarkerId[marker.id] = [] + let decorationsForMarker = this.decorationsByMarker.get(marker) + if (!decorationsForMarker) { + decorationsForMarker = [] + this.decorationsByMarker.set(marker, decorationsForMarker) } - this.decorationsByMarkerId[marker.id].push(decoration) + decorationsForMarker.push(decoration) if (decoration.isType('overlay')) { this.overlayDecorationsById[decoration.id] = decoration } @@ -189,10 +189,6 @@ class DecorationManager { return decoration } - decorationsForMarkerId (markerId) { - return this.decorationsByMarkerId[markerId] - } - emitDidUpdateDecorations () { this.emitter.emit('did-update-decorations') } @@ -206,9 +202,9 @@ class DecorationManager { } didDestroyMarkerDecoration (decoration) { - let decorations const {marker} = decoration - if (!(decorations = this.decorationsByMarkerId[marker.id])) return + const decorations = this.decorationsByMarker.get(marker) + if (!decorations) return const index = decorations.indexOf(decoration) if (index > -1) { @@ -216,7 +212,7 @@ class DecorationManager { delete this.decorationsById[decoration.id] this.emitter.emit('did-remove-decoration', decoration) if (decorations.length === 0) { - delete this.decorationsByMarkerId[marker.id] + delete this.decorationsByMarker.delete(marker) } delete this.overlayDecorationsById[decoration.id] this.unobserveDecoratedLayer(marker.layer) diff --git a/src/text-editor.coffee b/src/text-editor.coffee index aa0b0518670..2c42ecda80c 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -1852,9 +1852,6 @@ class TextEditor extends Model decorationForId: (id) -> @decorationManager.decorationForId(id) - decorationsForMarkerId: (id) -> - @decorationManager.decorationsForMarkerId(id) - ### Section: Markers ### From acf057e002dc7f642f44b6d298b2c2b75e43141e Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 3 Mar 2017 05:45:24 -0700 Subject: [PATCH 086/403] Replace overlayDecorationsById with overlayDecorations set --- src/decoration-manager.js | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/decoration-manager.js b/src/decoration-manager.js index 587df48ab6c..a1cb56f84e7 100644 --- a/src/decoration-manager.js +++ b/src/decoration-manager.js @@ -10,7 +10,7 @@ class DecorationManager { this.emitter = new Emitter() this.decorationsById = {} this.decorationsByMarker = new Map() - this.overlayDecorationsById = {} + this.overlayDecorations = new Set() this.layerDecorationsByMarkerLayerId = {} this.decorationCountsByLayerId = {} this.layerUpdateDisposablesByLayerId = {} @@ -69,10 +69,7 @@ class DecorationManager { getOverlayDecorations (propertyFilter) { const result = [] - for (let id in this.overlayDecorationsById) { - const decoration = this.overlayDecorationsById[id] - result.push(decoration) - } + result.push(...Array.from(this.overlayDecorations)) if (propertyFilter != null) { return result.filter(function (decoration) { for (let key in propertyFilter) { @@ -165,9 +162,7 @@ class DecorationManager { this.decorationsByMarker.set(marker, decorationsForMarker) } decorationsForMarker.push(decoration) - if (decoration.isType('overlay')) { - this.overlayDecorationsById[decoration.id] = decoration - } + if (decoration.isType('overlay')) this.overlayDecorations.add(decoration) this.decorationsById[decoration.id] = decoration this.observeDecoratedLayer(marker.layer) this.emitDidUpdateDecorations() @@ -195,9 +190,9 @@ class DecorationManager { decorationDidChangeType (decoration) { if (decoration.isType('overlay')) { - return (this.overlayDecorationsById[decoration.id] = decoration) + this.overlayDecorations.add(decoration) } else { - return delete this.overlayDecorationsById[decoration.id] + this.overlayDecorations.delete(decoration) } } @@ -214,7 +209,7 @@ class DecorationManager { if (decorations.length === 0) { delete this.decorationsByMarker.delete(marker) } - delete this.overlayDecorationsById[decoration.id] + this.overlayDecorations.delete(decoration) this.unobserveDecoratedLayer(marker.layer) } return this.emitDidUpdateDecorations() From 18acf8bb1979dcb1d7ecc76528385ea3f87ceec8 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 3 Mar 2017 05:49:15 -0700 Subject: [PATCH 087/403] Replace decorationsByMarkerLayerId with map keyed by layer --- src/decoration-manager.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/decoration-manager.js b/src/decoration-manager.js index a1cb56f84e7..0c5f1fec9d0 100644 --- a/src/decoration-manager.js +++ b/src/decoration-manager.js @@ -11,7 +11,7 @@ class DecorationManager { this.decorationsById = {} this.decorationsByMarker = new Map() this.overlayDecorations = new Set() - this.layerDecorationsByMarkerLayerId = {} + this.layerDecorationsByMarkerLayer = new Map() this.decorationCountsByLayerId = {} this.layerUpdateDisposablesByLayerId = {} } @@ -123,7 +123,7 @@ class DecorationManager { } } - const layerDecorations = this.layerDecorationsByMarkerLayerId[layerId] + const layerDecorations = this.layerDecorationsByMarkerLayer.get(layer) if (layerDecorations) { for (let layerDecoration of layerDecorations) { const properties = layerDecoration.overridePropertiesByMarkerId[marker.id] != null ? layerDecoration.overridePropertiesByMarkerId[marker.id] : layerDecoration.properties @@ -175,10 +175,12 @@ class DecorationManager { throw new Error('Cannot decorate a destroyed marker layer') } const decoration = new LayerDecoration(markerLayer, this, decorationParams) - if (this.layerDecorationsByMarkerLayerId[markerLayer.id] == null) { - this.layerDecorationsByMarkerLayerId[markerLayer.id] = [] + let layerDecorations = this.layerDecorationsByMarkerLayer.get(markerLayer) + if (layerDecorations == null) { + layerDecorations = [] + this.layerDecorationsByMarkerLayer.set(markerLayer, layerDecorations) } - this.layerDecorationsByMarkerLayerId[markerLayer.id].push(decoration) + layerDecorations.push(decoration) this.observeDecoratedLayer(markerLayer) this.emitDidUpdateDecorations() return decoration @@ -216,15 +218,15 @@ class DecorationManager { } didDestroyLayerDecoration (decoration) { - let decorations const {markerLayer} = decoration - if (!(decorations = this.layerDecorationsByMarkerLayerId[markerLayer.id])) return + const decorations = this.layerDecorationsByMarkerLayer.get(markerLayer) + if (!decorations) return const index = decorations.indexOf(decoration) if (index > -1) { decorations.splice(index, 1) if (decorations.length === 0) { - delete this.layerDecorationsByMarkerLayerId[markerLayer.id] + this.layerDecorationsByMarkerLayer.delete(markerLayer) } this.unobserveDecoratedLayer(markerLayer) } From a1faf66a85a686ecf89c857e2d2d5fbedb1d02f2 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 3 Mar 2017 05:56:27 -0700 Subject: [PATCH 088/403] Replace decorationCountsByLayerId with a map keyed by layer --- src/decoration-manager.js | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/decoration-manager.js b/src/decoration-manager.js index 0c5f1fec9d0..edc3739290e 100644 --- a/src/decoration-manager.js +++ b/src/decoration-manager.js @@ -12,7 +12,7 @@ class DecorationManager { this.decorationsByMarker = new Map() this.overlayDecorations = new Set() this.layerDecorationsByMarkerLayer = new Map() - this.decorationCountsByLayerId = {} + this.decorationCountsByLayer = new Map() this.layerUpdateDisposablesByLayerId = {} } @@ -87,9 +87,8 @@ class DecorationManager { decorationsForScreenRowRange (startScreenRow, endScreenRow) { const decorationsByMarkerId = {} - for (let layerId in this.decorationCountsByLayerId) { - const layer = this.displayLayer.getMarkerLayer(layerId) - for (let marker of layer.findMarkers({intersectsScreenRowRange: [startScreenRow, endScreenRow]})) { + for (const layer of this.decorationCountsByLayer.keys()) { + for (const marker of layer.findMarkers({intersectsScreenRowRange: [startScreenRow, endScreenRow]})) { const decorations = this.decorationsByMarker.get(marker) if (decorations) { decorationsByMarkerId[marker.id] = decorations @@ -102,10 +101,8 @@ class DecorationManager { decorationsStateForScreenRowRange (startScreenRow, endScreenRow) { const decorationsState = {} - for (let layerId in this.decorationCountsByLayerId) { - const layer = this.displayLayer.getMarkerLayer(layerId) - - for (let marker of layer.findMarkers({intersectsScreenRowRange: [startScreenRow, endScreenRow]})) { + for (const layer of this.decorationCountsByLayer.keys()) { + for (const marker of layer.findMarkers({intersectsScreenRowRange: [startScreenRow, endScreenRow]})) { if (marker.isValid()) { const screenRange = marker.getScreenRange() const bufferRange = marker.getBufferRange() @@ -234,19 +231,21 @@ class DecorationManager { } observeDecoratedLayer (layer) { - if (this.decorationCountsByLayerId[layer.id] == null) { - this.decorationCountsByLayerId[layer.id] = 0 - } - if (++this.decorationCountsByLayerId[layer.id] === 1) { + const newCount = (this.decorationCountsByLayer.get(layer) || 0) + 1 + this.decorationCountsByLayer.set(layer, newCount) + if (newCount === 1) { this.layerUpdateDisposablesByLayerId[layer.id] = layer.onDidUpdate(this.emitDidUpdateDecorations.bind(this)) } } unobserveDecoratedLayer (layer) { - if (--this.decorationCountsByLayerId[layer.id] === 0) { + const newCount = this.decorationCountsByLayer.get(layer) - 1 + if (newCount === 0) { this.layerUpdateDisposablesByLayerId[layer.id].dispose() - delete this.decorationCountsByLayerId[layer.id] + this.decorationCountsByLayer.delete(layer) delete this.layerUpdateDisposablesByLayerId[layer.id] + } else { + this.decorationCountsByLayer.set(layer, newCount) } } } From 69d5b63e9dbc6d3f97f8a145533ae67515563a94 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 3 Mar 2017 05:58:03 -0700 Subject: [PATCH 089/403] Replace layerUpdateDisposablesByLayerId with a weak map keyed by layer --- src/decoration-manager.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/decoration-manager.js b/src/decoration-manager.js index edc3739290e..16dd1f92171 100644 --- a/src/decoration-manager.js +++ b/src/decoration-manager.js @@ -13,7 +13,7 @@ class DecorationManager { this.overlayDecorations = new Set() this.layerDecorationsByMarkerLayer = new Map() this.decorationCountsByLayer = new Map() - this.layerUpdateDisposablesByLayerId = {} + this.layerUpdateDisposablesByLayer = new WeakMap() } observeDecorations (callback) { @@ -234,16 +234,15 @@ class DecorationManager { const newCount = (this.decorationCountsByLayer.get(layer) || 0) + 1 this.decorationCountsByLayer.set(layer, newCount) if (newCount === 1) { - this.layerUpdateDisposablesByLayerId[layer.id] = layer.onDidUpdate(this.emitDidUpdateDecorations.bind(this)) + this.layerUpdateDisposablesByLayer.set(layer, layer.onDidUpdate(this.emitDidUpdateDecorations.bind(this))) } } unobserveDecoratedLayer (layer) { const newCount = this.decorationCountsByLayer.get(layer) - 1 if (newCount === 0) { - this.layerUpdateDisposablesByLayerId[layer.id].dispose() + this.layerUpdateDisposablesByLayer.get(layer).dispose() this.decorationCountsByLayer.delete(layer) - delete this.layerUpdateDisposablesByLayerId[layer.id] } else { this.decorationCountsByLayer.set(layer, newCount) } From fbf21e09d68b590164a50911f8b019b61f5fd9a8 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 3 Mar 2017 06:01:23 -0700 Subject: [PATCH 090/403] Remove decorationsForId from DecorationManager --- src/decoration-manager.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/decoration-manager.js b/src/decoration-manager.js index 16dd1f92171..d00c8dded2d 100644 --- a/src/decoration-manager.js +++ b/src/decoration-manager.js @@ -8,7 +8,6 @@ class DecorationManager { this.displayLayer = displayLayer this.emitter = new Emitter() - this.decorationsById = {} this.decorationsByMarker = new Map() this.overlayDecorations = new Set() this.layerDecorationsByMarkerLayer = new Map() @@ -33,10 +32,6 @@ class DecorationManager { return this.emitter.on('did-update-decorations', callback) } - decorationForId (id) { - return this.decorationsById[id] - } - getDecorations (propertyFilter) { let allDecorations = [] @@ -160,7 +155,6 @@ class DecorationManager { } decorationsForMarker.push(decoration) if (decoration.isType('overlay')) this.overlayDecorations.add(decoration) - this.decorationsById[decoration.id] = decoration this.observeDecoratedLayer(marker.layer) this.emitDidUpdateDecorations() this.emitter.emit('did-add-decoration', decoration) @@ -203,7 +197,6 @@ class DecorationManager { if (index > -1) { decorations.splice(index, 1) - delete this.decorationsById[decoration.id] this.emitter.emit('did-remove-decoration', decoration) if (decorations.length === 0) { delete this.decorationsByMarker.delete(marker) From dbbf23d3a5befde451670d564acf8c1512a68346 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 3 Mar 2017 06:11:13 -0700 Subject: [PATCH 091/403] Store decorations in sets instead of arrays --- src/decoration-manager.js | 51 +++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 29 deletions(-) diff --git a/src/decoration-manager.js b/src/decoration-manager.js index d00c8dded2d..489857a6524 100644 --- a/src/decoration-manager.js +++ b/src/decoration-manager.js @@ -8,10 +8,10 @@ class DecorationManager { this.displayLayer = displayLayer this.emitter = new Emitter() + this.decorationCountsByLayer = new Map() this.decorationsByMarker = new Map() - this.overlayDecorations = new Set() this.layerDecorationsByMarkerLayer = new Map() - this.decorationCountsByLayer = new Map() + this.overlayDecorations = new Set() this.layerUpdateDisposablesByLayer = new WeakMap() } @@ -35,8 +35,8 @@ class DecorationManager { getDecorations (propertyFilter) { let allDecorations = [] - this.decorationsByMarker.forEach((decorations, marker) => { - if (decorations != null) allDecorations.push(...decorations) + this.decorationsByMarker.forEach((decorations) => { + decorations.forEach((decoration) => allDecorations.push(decoration)) }) if (propertyFilter != null) { allDecorations = allDecorations.filter(function (decoration) { @@ -86,7 +86,7 @@ class DecorationManager { for (const marker of layer.findMarkers({intersectsScreenRowRange: [startScreenRow, endScreenRow]})) { const decorations = this.decorationsByMarker.get(marker) if (decorations) { - decorationsByMarkerId[marker.id] = decorations + decorationsByMarkerId[marker.id] = Array.from(decorations) } } } @@ -105,19 +105,19 @@ class DecorationManager { const decorations = this.decorationsByMarker.get(marker.id) if (decorations) { - for (let decoration of decorations) { + decorations.forEach((decoration) => { decorationsState[decoration.id] = { properties: decoration.properties, screenRange, bufferRange, rangeIsReversed } - } + }) } const layerDecorations = this.layerDecorationsByMarkerLayer.get(layer) if (layerDecorations) { - for (let layerDecoration of layerDecorations) { + layerDecorations.forEach((layerDecoration) => { const properties = layerDecoration.overridePropertiesByMarkerId[marker.id] != null ? layerDecoration.overridePropertiesByMarkerId[marker.id] : layerDecoration.properties decorationsState[`${layerDecoration.id}-${marker.id}`] = { properties, @@ -125,7 +125,7 @@ class DecorationManager { bufferRange, rangeIsReversed } - } + }) } } } @@ -150,10 +150,10 @@ class DecorationManager { const decoration = new Decoration(marker, this, decorationParams) let decorationsForMarker = this.decorationsByMarker.get(marker) if (!decorationsForMarker) { - decorationsForMarker = [] + decorationsForMarker = new Set() this.decorationsByMarker.set(marker, decorationsForMarker) } - decorationsForMarker.push(decoration) + decorationsForMarker.add(decoration) if (decoration.isType('overlay')) this.overlayDecorations.add(decoration) this.observeDecoratedLayer(marker.layer) this.emitDidUpdateDecorations() @@ -168,10 +168,10 @@ class DecorationManager { const decoration = new LayerDecoration(markerLayer, this, decorationParams) let layerDecorations = this.layerDecorationsByMarkerLayer.get(markerLayer) if (layerDecorations == null) { - layerDecorations = [] + layerDecorations = new Set() this.layerDecorationsByMarkerLayer.set(markerLayer, layerDecorations) } - layerDecorations.push(decoration) + layerDecorations.add(decoration) this.observeDecoratedLayer(markerLayer) this.emitDidUpdateDecorations() return decoration @@ -192,35 +192,28 @@ class DecorationManager { didDestroyMarkerDecoration (decoration) { const {marker} = decoration const decorations = this.decorationsByMarker.get(marker) - if (!decorations) return - const index = decorations.indexOf(decoration) - - if (index > -1) { - decorations.splice(index, 1) - this.emitter.emit('did-remove-decoration', decoration) - if (decorations.length === 0) { - delete this.decorationsByMarker.delete(marker) - } + if (decorations && decorations.has(decoration)) { + decorations.delete(decoration) + if (decorations.size === 0) this.decorationsByMarker.delete(marker) this.overlayDecorations.delete(decoration) this.unobserveDecoratedLayer(marker.layer) + this.emitter.emit('did-remove-decoration', decoration) + this.emitDidUpdateDecorations() } - return this.emitDidUpdateDecorations() } didDestroyLayerDecoration (decoration) { const {markerLayer} = decoration const decorations = this.layerDecorationsByMarkerLayer.get(markerLayer) - if (!decorations) return - const index = decorations.indexOf(decoration) - if (index > -1) { - decorations.splice(index, 1) - if (decorations.length === 0) { + if (decorations && decorations.has(decoration)) { + decorations.delete(decoration) + if (decorations.size === 0) { this.layerDecorationsByMarkerLayer.delete(markerLayer) } this.unobserveDecoratedLayer(markerLayer) + this.emitDidUpdateDecorations() } - return this.emitDidUpdateDecorations() } observeDecoratedLayer (layer) { From e15e7e3c96e4f556ab74239625d28cee785ab8b0 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 3 Mar 2017 09:58:30 -0700 Subject: [PATCH 092/403] Assign width and character dimensions on editor to update soft wraps --- spec/text-editor-component-spec.js | 77 ++++++++++++++++++++++-------- src/text-editor-component.js | 49 +++++++++++++++---- src/text-editor.coffee | 9 ++-- static/text-editor.less | 11 ----- 4 files changed, 104 insertions(+), 42 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 75422ba6560..db91ee9911e 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -26,21 +26,6 @@ describe('TextEditorComponent', () => { jasmine.useRealClock() }) - function buildComponent (params = {}) { - const buffer = new TextBuffer({text: SAMPLE_TEXT}) - const editor = new TextEditor({buffer}) - const component = new TextEditorComponent({ - model: editor, - rowsPerTile: params.rowsPerTile, - updatedSynchronously: false - }) - const {element} = component - element.style.width = params.width ? params.width + 'px' : '800px' - element.style.height = params.height ? params.height + 'px' : '600px' - if (params.attach !== false) jasmine.attachToDOM(element) - return {component, element, editor} - } - it('renders lines and line numbers for the visible region', async () => { const {component, element, editor} = buildComponent({rowsPerTile: 3}) @@ -184,6 +169,33 @@ describe('TextEditorComponent', () => { expect(Math.round(hiddenInput.getBoundingClientRect().left)).toBe(clientLeftForCharacter(component, 7, 4)) }) + it('soft wraps lines based on the content width when soft wrap is enabled', async () => { + const {component, element, editor} = buildComponent({width: 435, attach: false}) + editor.setSoftWrapped(true) + jasmine.attachToDOM(element) + + expect(getBaseCharacterWidth(component)).toBe(55) + + console.log(element.offsetWidth); + expect(lineNodeForScreenRow(component, 3).textContent).toBe( + ' var pivot = items.shift(), current, left = [], ' + ) + expect(lineNodeForScreenRow(component, 4).textContent).toBe( + ' right = [];' + ) + + await setBaseCharacterWidth(component, 45) + expect(lineNodeForScreenRow(component, 3).textContent).toBe( + ' var pivot = items.shift(), current, left ' + ) + expect(lineNodeForScreenRow(component, 4).textContent).toBe( + ' = [], right = [];' + ) + + const {scroller} = component.refs + expect(scroller.clientWidth).toBe(scroller.scrollWidth) + }) + describe('focus', () => { it('focuses the hidden input element and adds the is-focused class when focused', async () => { assertDocumentFocused() @@ -316,10 +328,7 @@ describe('TextEditorComponent', () => { it('does not horizontally autoscroll by more than half of the visible "base-width" characters if the editor is narrower than twice the scroll margin', async () => { const {component, element, editor} = buildComponent() const {scroller, gutterContainer} = component.refs - element.style.width = - component.getGutterContainerWidth() + - 1.5 * editor.horizontalScrollMargin * component.measurements.baseCharacterWidth + 'px' - await component.getNextUpdatePromise() + await setBaseCharacterWidth(component, 1.5 * editor.horizontalScrollMargin) const contentWidth = scroller.clientWidth - gutterContainer.offsetWidth const contentWidthInCharacters = Math.floor(contentWidth / component.measurements.baseCharacterWidth) @@ -337,6 +346,36 @@ describe('TextEditorComponent', () => { }) }) +function buildComponent (params = {}) { + const buffer = new TextBuffer({text: SAMPLE_TEXT}) + const editor = new TextEditor({buffer}) + const component = new TextEditorComponent({ + model: editor, + rowsPerTile: params.rowsPerTile, + updatedSynchronously: false + }) + const {element} = component + element.style.width = params.width ? params.width + 'px' : '800px' + element.style.height = params.height ? params.height + 'px' : '600px' + if (params.attach !== false) jasmine.attachToDOM(element) + return {component, element, editor} +} + +function getBaseCharacterWidth (component) { + return Math.round( + (component.refs.scroller.clientWidth - component.getGutterContainerWidth()) / + component.measurements.baseCharacterWidth + ) +} + +async function setBaseCharacterWidth (component, widthInCharacters) { + component.element.style.width = + component.getGutterContainerWidth() + + widthInCharacters * component.measurements.baseCharacterWidth + + 'px' + await component.getNextUpdatePromise() +} + function verifyCursorPosition (component, cursorNode, row, column) { const rect = cursorNode.getBoundingClientRect() expect(Math.round(rect.top)).toBe(clientTopForLine(component, row)) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index fbb17668425..3719ae75beb 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -89,8 +89,10 @@ class TextEditorComponent { } render () { + const model = this.getModel() + let style - if (!this.getModel().getAutoHeight() && !this.getModel().getAutoWidth()) { + if (!model.getAutoHeight() && !model.getAutoWidth()) { style = {contain: 'strict'} } @@ -105,15 +107,32 @@ class TextEditorComponent { tabIndex: -1, on: {focus: this.didFocus} }, - $.div({ref: 'scroller', on: {scroll: this.didScroll}, className: 'scroll-view'}, - $.div({ + $.div( + { + ref: 'scroller', + className: 'scroll-view', + on: {scroll: this.didScroll}, style: { - isolate: 'content', - width: 'max-content', - height: 'max-content', + position: 'absolute', + contain: 'strict', + top: 0, + right: 0, + bottom: 0, + left: 0, + overflowX: model.isSoftWrapped() ? 'hidden' : 'auto', + overflowY: 'auto', backgroundColor: 'inherit' } }, + $.div( + { + style: { + isolate: 'content', + width: 'max-content', + height: 'max-content', + backgroundColor: 'inherit' + } + }, this.renderGutterContainer(), this.renderContent() ) @@ -418,8 +437,8 @@ class TextEditorComponent { didShow () { if (!this.visible) { this.visible = true - this.getModel().setVisible(true) if (!this.measurements) this.performInitialMeasurements() + this.getModel().setVisible(true) this.updateSync() } } @@ -676,11 +695,11 @@ class TextEditorComponent { performInitialMeasurements () { this.measurements = {} + this.measureGutterDimensions() this.measureEditorDimensions() this.measureClientDimensions() this.measureScrollPosition() this.measureCharacterDimensions() - this.measureGutterDimensions() } measureEditorDimensions () { @@ -721,6 +740,7 @@ class TextEditorComponent { } if (clientWidth !== this.measurements.clientWidth) { this.measurements.clientWidth = clientWidth + this.getModel().setWidth(clientWidth - this.getGutterContainerWidth(), true) clientDimensionsChanged = true } this.contentWidthOrHeightChanged = false @@ -733,6 +753,13 @@ class TextEditorComponent { this.measurements.doubleWidthCharacterWidth = this.refs.doubleWidthCharacterSpan.getBoundingClientRect().width this.measurements.halfWidthCharacterWidth = this.refs.halfWidthCharacterSpan.getBoundingClientRect().width this.measurements.koreanCharacterWidth = this.refs.koreanCharacterSpan.getBoundingClientRect().widt + + this.getModel().setDefaultCharWidth( + this.measurements.baseCharacterWidth, + this.measurements.doubleWidthCharacterWidth, + this.measurements.halfWidthCharacterWidth, + this.measurements.koreanCharacterWidth + ) } checkForNewLongestLine () { @@ -905,7 +932,11 @@ class TextEditorComponent { } getContentWidth () { - return Math.round(this.measurements.longestLineWidth + this.measurements.baseCharacterWidth) + if (this.getModel().isSoftWrapped()) { + return this.getClientWidth() - this.getGutterContainerWidth() + } else { + return Math.round(this.measurements.longestLineWidth + this.measurements.baseCharacterWidth) + } } getRowsPerTile () { diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 2c42ecda80c..1e8ddcb5201 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -3596,7 +3596,10 @@ class TextEditor extends Model @doubleWidthCharWidth = doubleWidthCharWidth @halfWidthCharWidth = halfWidthCharWidth @koreanCharWidth = koreanCharWidth - @displayLayer.reset({}) if @isSoftWrapped() and @getEditorWidthInChars()? + if @isSoftWrapped() + @displayLayer.reset({ + softWrapColumn: @getSoftWrapColumn() + }) defaultCharWidth setHeight: (height, reentrant=false) -> @@ -3614,8 +3617,8 @@ class TextEditor extends Model getAutoWidth: -> @autoWidth ? false - setWidth: (width, reentrant=false) -> - if reentrant + setWidth: (width, fromComponent=false) -> + if fromComponent @update({width}) @width else diff --git a/static/text-editor.less b/static/text-editor.less index 71482f5f53e..850907b671c 100644 --- a/static/text-editor.less +++ b/static/text-editor.less @@ -1,17 +1,6 @@ atom-text-editor { position: relative; - .scroll-view { - position: absolute; - contain: strict; - top: 0; - right: 0; - bottom: 0; - left: 0; - overflow: auto; - background-color: inherit; - } - .gutter-container { float: left; width: min-content; From ff325c0151b39b491f11468b386d350a797ae598 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 6 Mar 2017 13:35:25 -0700 Subject: [PATCH 093/403] Render line and line number decorations --- spec/decoration-manager-spec.coffee | 4 -- spec/text-editor-component-spec.js | 79 ++++++++++++++++++++- src/decoration-manager.js | 55 +++++++++++++-- src/layer-decoration.coffee | 14 ++-- src/text-editor-component.js | 103 +++++++++++++++++++++++++--- src/text-editor.coffee | 3 - 6 files changed, 227 insertions(+), 31 deletions(-) diff --git a/spec/decoration-manager-spec.coffee b/spec/decoration-manager-spec.coffee index ba5de0cf230..ecef2bcc285 100644 --- a/spec/decoration-manager-spec.coffee +++ b/spec/decoration-manager-spec.coffee @@ -28,7 +28,6 @@ describe "DecorationManager", -> it "can add decorations associated with markers and remove them", -> expect(layer1MarkerDecoration).toBeDefined() expect(layer1MarkerDecoration.getProperties()).toBe decorationProperties - expect(decorationManager.decorationForId(layer1MarkerDecoration.id)).toBe layer1MarkerDecoration expect(decorationManager.decorationsForScreenRowRange(2, 3)).toEqual { "#{layer1Marker.id}": [layer1MarkerDecoration], "#{layer2Marker.id}": [layer2MarkerDecoration] @@ -36,15 +35,12 @@ describe "DecorationManager", -> layer1MarkerDecoration.destroy() expect(decorationManager.decorationsForScreenRowRange(2, 3)[layer1Marker.id]).not.toBeDefined() - expect(decorationManager.decorationForId(layer1MarkerDecoration.id)).not.toBeDefined() layer2MarkerDecoration.destroy() expect(decorationManager.decorationsForScreenRowRange(2, 3)[layer2Marker.id]).not.toBeDefined() - expect(decorationManager.decorationForId(layer2MarkerDecoration.id)).not.toBeDefined() it "will not fail if the decoration is removed twice", -> layer1MarkerDecoration.destroy() layer1MarkerDecoration.destroy() - expect(decorationManager.decorationForId(layer1MarkerDecoration.id)).not.toBeDefined() it "does not allow destroyed markers to be decorated", -> layer1Marker.destroy() diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index db91ee9911e..bc33382d798 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -175,8 +175,6 @@ describe('TextEditorComponent', () => { jasmine.attachToDOM(element) expect(getBaseCharacterWidth(component)).toBe(55) - - console.log(element.offsetWidth); expect(lineNodeForScreenRow(component, 3).textContent).toBe( ' var pivot = items.shift(), current, left = [], ' ) @@ -344,6 +342,74 @@ describe('TextEditorComponent', () => { expect(scroller.scrollLeft).toBe(expectedScrollLeft) }) }) + + describe('line and line number decorations', () => { + it('adds decoration classes on screen lines spanned by decorated markers', async () => { + const {component, element, editor} = buildComponent({width: 435, attach: false}) + editor.setSoftWrapped(true) + jasmine.attachToDOM(element) + + expect(lineNodeForScreenRow(component, 3).textContent).toBe( + ' var pivot = items.shift(), current, left = [], ' + ) + expect(lineNodeForScreenRow(component, 4).textContent).toBe( + ' right = [];' + ) + + const marker1 = editor.markScreenRange([[1, 10], [3, 10]]) + const layer = editor.addMarkerLayer() + const marker2 = layer.markScreenPosition([5, 0]) + const marker3 = layer.markScreenPosition([8, 0]) + const marker4 = layer.markScreenPosition([10, 0]) + const markerDecoration = editor.decorateMarker(marker1, {type: ['line', 'line-number'], class: 'a'}) + const layerDecoration = editor.decorateMarkerLayer(layer, {type: ['line', 'line-number'], class: 'b'}) + layerDecoration.setPropertiesForMarker(marker4, {type: 'line', class: 'c'}) + await component.getNextUpdatePromise() + + expect(lineNodeForScreenRow(component, 1).classList.contains('a')).toBe(true) + expect(lineNodeForScreenRow(component, 2).classList.contains('a')).toBe(true) + expect(lineNodeForScreenRow(component, 3).classList.contains('a')).toBe(true) + expect(lineNodeForScreenRow(component, 4).classList.contains('a')).toBe(false) + expect(lineNodeForScreenRow(component, 5).classList.contains('b')).toBe(true) + expect(lineNodeForScreenRow(component, 8).classList.contains('b')).toBe(true) + expect(lineNodeForScreenRow(component, 10).classList.contains('b')).toBe(false) + expect(lineNodeForScreenRow(component, 10).classList.contains('c')).toBe(true) + + expect(lineNumberNodeForScreenRow(component, 1).classList.contains('a')).toBe(true) + expect(lineNumberNodeForScreenRow(component, 2).classList.contains('a')).toBe(true) + expect(lineNumberNodeForScreenRow(component, 3).classList.contains('a')).toBe(true) + expect(lineNumberNodeForScreenRow(component, 4).classList.contains('a')).toBe(false) + expect(lineNumberNodeForScreenRow(component, 5).classList.contains('b')).toBe(true) + expect(lineNumberNodeForScreenRow(component, 8).classList.contains('b')).toBe(true) + expect(lineNumberNodeForScreenRow(component, 10).classList.contains('b')).toBe(false) + expect(lineNumberNodeForScreenRow(component, 10).classList.contains('c')).toBe(false) + + marker1.setScreenRange([[5, 0], [8, 0]]) + await component.getNextUpdatePromise() + + expect(lineNodeForScreenRow(component, 1).classList.contains('a')).toBe(false) + expect(lineNodeForScreenRow(component, 2).classList.contains('a')).toBe(false) + expect(lineNodeForScreenRow(component, 3).classList.contains('a')).toBe(false) + expect(lineNodeForScreenRow(component, 4).classList.contains('a')).toBe(false) + expect(lineNodeForScreenRow(component, 5).classList.contains('a')).toBe(true) + expect(lineNodeForScreenRow(component, 5).classList.contains('b')).toBe(true) + expect(lineNodeForScreenRow(component, 6).classList.contains('a')).toBe(true) + expect(lineNodeForScreenRow(component, 7).classList.contains('a')).toBe(true) + expect(lineNodeForScreenRow(component, 8).classList.contains('a')).toBe(true) + expect(lineNodeForScreenRow(component, 8).classList.contains('b')).toBe(true) + + expect(lineNumberNodeForScreenRow(component, 1).classList.contains('a')).toBe(false) + expect(lineNumberNodeForScreenRow(component, 2).classList.contains('a')).toBe(false) + expect(lineNumberNodeForScreenRow(component, 3).classList.contains('a')).toBe(false) + expect(lineNumberNodeForScreenRow(component, 4).classList.contains('a')).toBe(false) + expect(lineNumberNodeForScreenRow(component, 5).classList.contains('a')).toBe(true) + expect(lineNumberNodeForScreenRow(component, 5).classList.contains('b')).toBe(true) + expect(lineNumberNodeForScreenRow(component, 6).classList.contains('a')).toBe(true) + expect(lineNumberNodeForScreenRow(component, 7).classList.contains('a')).toBe(true) + expect(lineNumberNodeForScreenRow(component, 8).classList.contains('a')).toBe(true) + expect(lineNumberNodeForScreenRow(component, 8).classList.contains('b')).toBe(true) + }) + }) }) function buildComponent (params = {}) { @@ -401,6 +467,15 @@ function clientLeftForCharacter (component, row, column) { } } +function lineNumberNodeForScreenRow (component, row) { + const gutterElement = component.refs.lineNumberGutter.element + const endRow = Math.min(component.getRenderedEndRow(), component.getModel().getApproximateScreenLineCount()) + const visibleTileCount = Math.ceil((endRow - component.getRenderedStartRow()) / component.getRowsPerTile()) + const tileStartRow = component.getTileStartRow(row) + const tileIndex = (tileStartRow / component.getRowsPerTile()) % visibleTileCount + return gutterElement.children[tileIndex].children[row - tileStartRow] +} + function lineNodeForScreenRow (component, row) { const screenLine = component.getModel().screenLineForScreenRow(row) return component.lineNodesByScreenLineId.get(screenLine.id) diff --git a/src/decoration-manager.js b/src/decoration-manager.js index 489857a6524..7a99d5809d7 100644 --- a/src/decoration-manager.js +++ b/src/decoration-manager.js @@ -9,6 +9,7 @@ class DecorationManager { this.emitter = new Emitter() this.decorationCountsByLayer = new Map() + this.markerDecorationCountsByLayer = new Map() this.decorationsByMarker = new Map() this.layerDecorationsByMarkerLayer = new Map() this.overlayDecorations = new Set() @@ -80,6 +81,40 @@ class DecorationManager { } } + decorationPropertiesByMarkerForScreenRowRange (startScreenRow, endScreenRow) { + const decorationPropertiesByMarker = new Map() + + this.decorationCountsByLayer.forEach((count, markerLayer) => { + const markers = markerLayer.findMarkers({intersectsScreenRowRange: [startScreenRow, endScreenRow - 1]}) + const layerDecorations = this.layerDecorationsByMarkerLayer.get(markerLayer) + const hasMarkerDecorations = this.markerDecorationCountsByLayer.get(markerLayer) > 0 + + for (let i = 0; i < markers.length; i++) { + const marker = markers[i] + + let decorationPropertiesForMarker = decorationPropertiesByMarker.get(marker) + if (decorationPropertiesForMarker == null) { + decorationPropertiesForMarker = [] + decorationPropertiesByMarker.set(marker, decorationPropertiesForMarker) + } + + if (layerDecorations) { + layerDecorations.forEach((layerDecoration) => { + decorationPropertiesForMarker.push(layerDecoration.getPropertiesForMarker(marker) || layerDecoration.getProperties()) + }) + } + + if (hasMarkerDecorations) { + this.decorationsByMarker.get(marker).forEach((decoration) => { + decorationPropertiesForMarker.push(decoration.getProperties()) + }) + } + } + }) + + return decorationPropertiesByMarker + } + decorationsForScreenRowRange (startScreenRow, endScreenRow) { const decorationsByMarkerId = {} for (const layer of this.decorationCountsByLayer.keys()) { @@ -118,7 +153,7 @@ class DecorationManager { const layerDecorations = this.layerDecorationsByMarkerLayer.get(layer) if (layerDecorations) { layerDecorations.forEach((layerDecoration) => { - const properties = layerDecoration.overridePropertiesByMarkerId[marker.id] != null ? layerDecoration.overridePropertiesByMarkerId[marker.id] : layerDecoration.properties + const properties = layerDecoration.getPropertiesForMarker(marker) || layerDecoration.getProperties() decorationsState[`${layerDecoration.id}-${marker.id}`] = { properties, screenRange, @@ -155,7 +190,7 @@ class DecorationManager { } decorationsForMarker.add(decoration) if (decoration.isType('overlay')) this.overlayDecorations.add(decoration) - this.observeDecoratedLayer(marker.layer) + this.observeDecoratedLayer(marker.layer, true) this.emitDidUpdateDecorations() this.emitter.emit('did-add-decoration', decoration) return decoration @@ -172,7 +207,7 @@ class DecorationManager { this.layerDecorationsByMarkerLayer.set(markerLayer, layerDecorations) } layerDecorations.add(decoration) - this.observeDecoratedLayer(markerLayer) + this.observeDecoratedLayer(markerLayer, false) this.emitDidUpdateDecorations() return decoration } @@ -196,7 +231,7 @@ class DecorationManager { decorations.delete(decoration) if (decorations.size === 0) this.decorationsByMarker.delete(marker) this.overlayDecorations.delete(decoration) - this.unobserveDecoratedLayer(marker.layer) + this.unobserveDecoratedLayer(marker.layer, true) this.emitter.emit('did-remove-decoration', decoration) this.emitDidUpdateDecorations() } @@ -211,20 +246,23 @@ class DecorationManager { if (decorations.size === 0) { this.layerDecorationsByMarkerLayer.delete(markerLayer) } - this.unobserveDecoratedLayer(markerLayer) + this.unobserveDecoratedLayer(markerLayer, true) this.emitDidUpdateDecorations() } } - observeDecoratedLayer (layer) { + observeDecoratedLayer (layer, isMarkerDecoration) { const newCount = (this.decorationCountsByLayer.get(layer) || 0) + 1 this.decorationCountsByLayer.set(layer, newCount) if (newCount === 1) { this.layerUpdateDisposablesByLayer.set(layer, layer.onDidUpdate(this.emitDidUpdateDecorations.bind(this))) } + if (isMarkerDecoration) { + this.markerDecorationCountsByLayer.set(layer, (this.markerDecorationCountsByLayer.get(layer) || 0) + 1) + } } - unobserveDecoratedLayer (layer) { + unobserveDecoratedLayer (layer, isMarkerDecoration) { const newCount = this.decorationCountsByLayer.get(layer) - 1 if (newCount === 0) { this.layerUpdateDisposablesByLayer.get(layer).dispose() @@ -232,5 +270,8 @@ class DecorationManager { } else { this.decorationCountsByLayer.set(layer, newCount) } + if (isMarkerDecoration) { + this.markerDecorationCountsByLayer.set(this.markerDecorationCountsByLayer.get(layer) - 1) + } } } diff --git a/src/layer-decoration.coffee b/src/layer-decoration.coffee index fb544948f40..03be59b14f5 100644 --- a/src/layer-decoration.coffee +++ b/src/layer-decoration.coffee @@ -9,7 +9,7 @@ class LayerDecoration @id = nextId() @destroyed = false @markerLayerDestroyedDisposable = @markerLayer.onDidDestroy => @destroy() - @overridePropertiesByMarkerId = {} + @overridePropertiesByMarker = null # Essential: Destroys the decoration. destroy: -> @@ -42,7 +42,7 @@ class LayerDecoration setProperties: (newProperties) -> return if @destroyed @properties = newProperties - @decorationManager.scheduleUpdateDecorationsEvent() + @decorationManager.emitDidUpdateDecorations() # Essential: Override the decoration properties for a specific marker. # @@ -52,8 +52,12 @@ class LayerDecoration # Pass `null` to clear the override. setPropertiesForMarker: (marker, properties) -> return if @destroyed + @overridePropertiesByMarker ?= new Map() if properties? - @overridePropertiesByMarkerId[marker.id] = properties + @overridePropertiesByMarker.set(marker, properties) else - delete @overridePropertiesByMarkerId[marker.id] - @decorationManager.scheduleUpdateDecorationsEvent() + @overridePropertiesByMarker.delete(marker.id) + @decorationManager.emitDidUpdateDecorations() + + getPropertiesForMarker: (marker) -> + @overridePropertiesByMarker?.get(marker) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 3719ae75beb..1f6db370fee 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -38,6 +38,10 @@ class TextEditorComponent { this.lastKeydownBeforeKeypress = null this.openedAccentedCharacterMenu = false this.cursorsToRender = [] + this.decorationsToRender = { + lineNumbers: new Map(), + lines: new Map() + } if (this.props.model) this.observeModel() resizeDetector.listenTo(this.element, this.didResize.bind(this)) @@ -74,6 +78,7 @@ class TextEditorComponent { if (this.pendingAutoscroll) this.initiateAutoscroll() this.populateVisibleRowRange() const longestLineToMeasure = this.checkForNewLongestLine() + this.queryDecorationsToRender() this.queryCursorsToRender() etch.updateSync(this) @@ -166,9 +171,11 @@ class TextEditorComponent { if (this.measurements) { const startRow = this.getRenderedStartRow() const endRow = Math.min(model.getApproximateScreenLineCount(), this.getRenderedEndRow()) - const bufferRows = new Array(endRow - startRow) - const foldableFlags = new Array(endRow - startRow) - const softWrappedFlags = new Array(endRow - startRow) + const visibleRowCount = endRow - startRow + const bufferRows = new Array(visibleRowCount) + const foldableFlags = new Array(visibleRowCount) + const softWrappedFlags = new Array(visibleRowCount) + const lineNumberDecorations = new Array(visibleRowCount) let previousBufferRow = (startRow > 0) ? model.bufferRowForScreenRow(startRow - 1) : -1 for (let row = startRow; row < endRow; row++) { @@ -177,17 +184,20 @@ class TextEditorComponent { bufferRows[i] = bufferRow softWrappedFlags[i] = bufferRow === previousBufferRow foldableFlags[i] = model.isFoldableAtBufferRow(bufferRow) + lineNumberDecorations[i] = this.decorationsToRender.lineNumbers.get(row) previousBufferRow = bufferRow } const rowsPerTile = this.getRowsPerTile() this.currentFrameLineNumberGutterProps = { + ref: 'lineNumberGutter', height: this.getScrollHeight(), width: this.measurements.lineNumberGutterWidth, lineHeight: this.measurements.lineHeight, startRow, endRow, rowsPerTile, maxLineNumberDigits, - bufferRows, softWrappedFlags, foldableFlags + bufferRows, lineNumberDecorations, softWrappedFlags, + foldableFlags } return $(LineNumberGutterComponent, this.currentFrameLineNumberGutterProps) @@ -265,12 +275,18 @@ class TextEditorComponent { const tileHeight = rowsPerTile * this.measurements.lineHeight const tileIndex = (tileStartRow / rowsPerTile) % visibleTileCount + const lineDecorations = new Array(rowsPerTile) + for (let row = tileStartRow; row < tileEndRow; row++) { + lineDecorations[row - tileStartRow] = this.decorationsToRender.lines.get(row) + } + tileNodes[tileIndex] = $(LinesTileComponent, { key: tileIndex, height: tileHeight, width: tileWidth, top: this.topPixelPositionForRow(tileStartRow), screenLines: screenLines.slice(tileStartRow - startRow, tileEndRow - startRow), + lineDecorations, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId @@ -393,6 +409,52 @@ class TextEditorComponent { } } + queryDecorationsToRender () { + this.decorationsToRender.lineNumbers.clear() + this.decorationsToRender.lines.clear() + + const decorationsByMarker = + this.getModel().decorationManager.decorationPropertiesByMarkerForScreenRowRange( + this.getRenderedStartRow(), + this.getRenderedEndRow() + ) + + decorationsByMarker.forEach((decorations, marker) => { + const screenRange = marker.getScreenRange() + const reversed = marker.isReversed() + for (let i = 0, length = decorations.length; i < decorations.length; i++) { + const decoration = decorations[i] + this.addToDecorationsToRender(decoration.type, decoration, screenRange, reversed) + } + }) + } + + addToDecorationsToRender (type, decoration, screenRange, reversed) { + if (Array.isArray(type)) { + for (let i = 0, length = type.length; i < length; i++) { + this.addToDecorationsToRender(type[i], decoration, screenRange, reversed) + } + } else { + switch (type) { + case 'line-number': + for (let row = screenRange.start.row; row <= screenRange.end.row; row++) { + const currentClassName = this.decorationsToRender.lineNumbers.get(row) + const newClassName = currentClassName ? currentClassName + ' ' + decoration.class : decoration.class + this.decorationsToRender.lineNumbers.set(row, newClassName) + } + break + case 'line': + for (let row = screenRange.start.row; row <= screenRange.end.row; row++) { + const currentClassName = this.decorationsToRender.lines.get(row) + const newClassName = currentClassName ? currentClassName + ' ' + decoration.class : decoration.class + this.decorationsToRender.lines.set(row, newClassName) + } + break + } + } + } + + positionCursorsToRender () { const height = this.measurements.lineHeight + 'px' for (let i = 0; i < this.cursorsToRender.length; i++) { @@ -878,6 +940,7 @@ class TextEditorComponent { const scheduleUpdate = this.scheduleUpdate.bind(this) this.disposables.add(model.selectionsMarkerLayer.onDidUpdate(scheduleUpdate)) this.disposables.add(model.displayLayer.onDidChangeSync(scheduleUpdate)) + this.disposables.add(model.onDidUpdateDecorations(scheduleUpdate)) this.disposables.add(model.onDidRequestAutoscroll(this.didRequestAutoscroll.bind(this))) } @@ -1017,7 +1080,8 @@ class LineNumberGutterComponent { render () { const { height, width, lineHeight, startRow, endRow, rowsPerTile, - maxLineNumberDigits, bufferRows, softWrappedFlags, foldableFlags + maxLineNumberDigits, bufferRows, softWrappedFlags, foldableFlags, + lineNumberDecorations } = this.props const visibleTileCount = Math.ceil((endRow - startRow) / rowsPerTile) @@ -1046,6 +1110,10 @@ class LineNumberGutterComponent { lineNumber = (bufferRow + 1).toString() if (foldable) className += ' foldable' } + + const lineNumberDecoration = lineNumberDecorations[i] + if (lineNumberDecoration != null) className += ' ' + lineNumberDecoration + lineNumber = NBSP_CHARACTER.repeat(maxLineNumberDigits - lineNumber.length) + lineNumber tileChildren[row - tileStartRow] = $.div({key, className}, @@ -1100,6 +1168,7 @@ class LineNumberGutterComponent { if (!arraysEqual(oldProps.bufferRows, newProps.bufferRows)) return true if (!arraysEqual(oldProps.softWrappedFlags, newProps.softWrappedFlags)) return true if (!arraysEqual(oldProps.foldableFlags, newProps.foldableFlags)) return true + if (!arraysEqual(oldProps.lineNumberDecorations, newProps.lineNumberDecorations)) return true return false } } @@ -1120,8 +1189,8 @@ class LinesTileComponent { render () { const { height, width, top, - screenLines, displayLayer, - lineNodesByScreenLineId, textNodesByScreenLineId + screenLines, lineDecorations, displayLayer, + lineNodesByScreenLineId, textNodesByScreenLineId, } = this.props const children = new Array(screenLines.length) @@ -1134,6 +1203,7 @@ class LinesTileComponent { children[i] = $(LineComponent, { key: screenLine.id, screenLine, + lineDecoration: lineDecorations[i], displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId @@ -1159,16 +1229,17 @@ class LinesTileComponent { if (oldProps.height !== newProps.height) return true if (oldProps.width !== newProps.width) return true if (!arraysEqual(oldProps.screenLines, newProps.screenLines)) return true + if (!arraysEqual(oldProps.lineDecorations, newProps.lineDecorations)) return true return false } } class LineComponent { constructor (props) { - const {displayLayer, screenLine, lineNodesByScreenLineId, textNodesByScreenLineId} = props + const {displayLayer, screenLine, lineDecoration, lineNodesByScreenLineId, textNodesByScreenLineId} = props this.props = props this.element = document.createElement('div') - this.element.classList.add('line') + this.element.className = this.buildClassName() lineNodesByScreenLineId.set(screenLine.id, this.element) const textNodes = [] @@ -1214,7 +1285,12 @@ class LineComponent { } } - update () {} + update (newProps) { + if (this.props.lineDecoration !== newProps.lineDecoration) { + this.props = newProps + this.element.className = this.buildClassName() + } + } destroy () { const {lineNodesByScreenLineId, textNodesByScreenLineId, screenLine} = this.props @@ -1223,6 +1299,13 @@ class LineComponent { textNodesByScreenLineId.delete(screenLine.id) } } + + buildClassName () { + const {lineDecoration} = this.props + let className = 'line' + if (lineDecoration != null) className += ' ' + lineDecoration + return className + } } const classNamesByScopeName = new Map() diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 1e8ddcb5201..a94b6b0b1cf 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -1849,9 +1849,6 @@ class TextEditor extends Model getOverlayDecorations: (propertyFilter) -> @decorationManager.getOverlayDecorations(propertyFilter) - decorationForId: (id) -> - @decorationManager.decorationForId(id) - ### Section: Markers ### From 09f8a52b9d8e4d323ac7218af9f12f8ef8ac7853 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 6 Mar 2017 14:54:21 -0700 Subject: [PATCH 094/403] Implement special options for line and line number decorations * onlyEmpty * onlyNonEmpty * onlyHead * omitEmptyLastLine --- spec/text-editor-component-spec.js | 58 ++++++++++++++++++++++++++++++ src/text-editor-component.js | 42 +++++++++++++++------- src/text-editor.coffee | 12 ++++--- 3 files changed, 96 insertions(+), 16 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index bc33382d798..db82b507708 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -409,6 +409,64 @@ describe('TextEditorComponent', () => { expect(lineNumberNodeForScreenRow(component, 8).classList.contains('a')).toBe(true) expect(lineNumberNodeForScreenRow(component, 8).classList.contains('b')).toBe(true) }) + + it('honors the onlyEmpty and onlyNonEmpty decoration options', async () => { + const {component, element, editor} = buildComponent() + const marker = editor.markScreenPosition([1, 0]) + editor.decorateMarker(marker, {type: ['line', 'line-number'], class: 'a', onlyEmpty: true}) + editor.decorateMarker(marker, {type: ['line', 'line-number'], class: 'b', onlyNonEmpty: true}) + editor.decorateMarker(marker, {type: ['line', 'line-number'], class: 'c'}) + await component.getNextUpdatePromise() + + expect(lineNodeForScreenRow(component, 1).classList.contains('a')).toBe(true) + expect(lineNodeForScreenRow(component, 1).classList.contains('b')).toBe(false) + expect(lineNodeForScreenRow(component, 1).classList.contains('c')).toBe(true) + expect(lineNumberNodeForScreenRow(component, 1).classList.contains('a')).toBe(true) + expect(lineNumberNodeForScreenRow(component, 1).classList.contains('b')).toBe(false) + expect(lineNumberNodeForScreenRow(component, 1).classList.contains('c')).toBe(true) + + marker.setScreenRange([[1, 0], [2, 4]]) + await component.getNextUpdatePromise() + + expect(lineNodeForScreenRow(component, 1).classList.contains('a')).toBe(false) + expect(lineNodeForScreenRow(component, 1).classList.contains('b')).toBe(true) + expect(lineNodeForScreenRow(component, 1).classList.contains('c')).toBe(true) + expect(lineNodeForScreenRow(component, 2).classList.contains('b')).toBe(true) + expect(lineNodeForScreenRow(component, 2).classList.contains('c')).toBe(true) + expect(lineNumberNodeForScreenRow(component, 1).classList.contains('a')).toBe(false) + expect(lineNumberNodeForScreenRow(component, 1).classList.contains('b')).toBe(true) + expect(lineNumberNodeForScreenRow(component, 1).classList.contains('c')).toBe(true) + expect(lineNumberNodeForScreenRow(component, 2).classList.contains('b')).toBe(true) + expect(lineNumberNodeForScreenRow(component, 2).classList.contains('c')).toBe(true) + }) + + it('honors the onlyHead option', async () => { + const {component, element, editor} = buildComponent() + const marker = editor.markScreenRange([[1, 4], [3, 4]]) + editor.decorateMarker(marker, {type: ['line', 'line-number'], class: 'a', onlyHead: true}) + await component.getNextUpdatePromise() + + expect(lineNodeForScreenRow(component, 1).classList.contains('a')).toBe(false) + expect(lineNodeForScreenRow(component, 3).classList.contains('a')).toBe(true) + expect(lineNumberNodeForScreenRow(component, 1).classList.contains('a')).toBe(false) + expect(lineNumberNodeForScreenRow(component, 3).classList.contains('a')).toBe(true) + }) + + it('only decorates the last row of non-empty ranges that end at column 0 if omitEmptyLastRow is false', async () => { + const {component, element, editor} = buildComponent() + const marker = editor.markScreenRange([[1, 0], [3, 0]]) + editor.decorateMarker(marker, {type: ['line', 'line-number'], class: 'a'}) + editor.decorateMarker(marker, {type: ['line', 'line-number'], class: 'b', omitEmptyLastRow: false}) + await component.getNextUpdatePromise() + + expect(lineNodeForScreenRow(component, 1).classList.contains('a')).toBe(true) + expect(lineNodeForScreenRow(component, 2).classList.contains('a')).toBe(true) + expect(lineNodeForScreenRow(component, 3).classList.contains('a')).toBe(false) + + expect(lineNodeForScreenRow(component, 1).classList.contains('b')).toBe(true) + expect(lineNodeForScreenRow(component, 2).classList.contains('b')).toBe(true) + expect(lineNodeForScreenRow(component, 3).classList.contains('b')).toBe(true) + }) }) }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 1f6db370fee..06e3d5abd60 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -424,30 +424,48 @@ class TextEditorComponent { const reversed = marker.isReversed() for (let i = 0, length = decorations.length; i < decorations.length; i++) { const decoration = decorations[i] - this.addToDecorationsToRender(decoration.type, decoration, screenRange, reversed) + this.addDecorationToRender(decoration.type, decoration, screenRange, reversed) } }) } - addToDecorationsToRender (type, decoration, screenRange, reversed) { + addDecorationToRender (type, decoration, screenRange, reversed) { if (Array.isArray(type)) { for (let i = 0, length = type.length; i < length; i++) { - this.addToDecorationsToRender(type[i], decoration, screenRange, reversed) + this.addDecorationToRender(type[i], decoration, screenRange, reversed) } } else { switch (type) { + case 'line': case 'line-number': - for (let row = screenRange.start.row; row <= screenRange.end.row; row++) { - const currentClassName = this.decorationsToRender.lineNumbers.get(row) - const newClassName = currentClassName ? currentClassName + ' ' + decoration.class : decoration.class - this.decorationsToRender.lineNumbers.set(row, newClassName) + const decorationsByRow = (type === 'line') ? this.decorationsToRender.lines : this.decorationsToRender.lineNumbers + + let omitLastRow = false + if (screenRange.isEmpty()) { + if (decoration.onlyNonEmpty) return + } else { + if (decoration.onlyEmpty) return + if (decoration.omitEmptyLastRow !== false) { + omitLastRow = screenRange.end.column === 0 + } } - break - case 'line': - for (let row = screenRange.start.row; row <= screenRange.end.row; row++) { - const currentClassName = this.decorationsToRender.lines.get(row) + + let startRow = screenRange.start.row + let endRow = screenRange.end.row + + if (decoration.onlyHead) { + if (reversed) { + endRow = startRow + } else { + startRow = endRow + } + } + + for (let row = startRow; row <= endRow; row++) { + if (omitLastRow && row === endRow) break + const currentClassName = decorationsByRow.get(row) const newClassName = currentClassName ? currentClassName + ' ' + decoration.class : decoration.class - this.decorationsToRender.lines.set(row, newClassName) + decorationsByRow.set(row, newClassName) } break } diff --git a/src/text-editor.coffee b/src/text-editor.coffee index a94b6b0b1cf..540e1d2fddc 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -1752,16 +1752,20 @@ class TextEditor extends Model # line, highlight, or overlay. # * `item` (optional) An {HTMLElement} or a model {Object} with a # corresponding view registered. Only applicable to the `gutter`, - # `overlay` and `block` types. + # `overlay` and `block` decoration types. # * `onlyHead` (optional) If `true`, the decoration will only be applied to # the head of the `DisplayMarker`. Only applicable to the `line` and - # `line-number` types. + # `line-number` decoration types. # * `onlyEmpty` (optional) If `true`, the decoration will only be applied if # the associated `DisplayMarker` is empty. Only applicable to the `gutter`, - # `line`, and `line-number` types. + # `line`, and `line-number` decoration types. # * `onlyNonEmpty` (optional) If `true`, the decoration will only be applied # if the associated `DisplayMarker` is non-empty. Only applicable to the - # `gutter`, `line`, and `line-number` types. + # `gutter`, `line`, and `line-number` decoration types. + # * `omitEmptyLastRow` (optional) If `false`, the decoration will be applied + # to the last row of a non-empty range, even if it ends at column 0. + # Defaults to `true`. Only applicable to the `gutter`, `line`, and + # `line-number` decoration types. # * `position` (optional) Only applicable to decorations of type `overlay` and `block`. # Controls where the view is positioned relative to the `TextEditorMarker`. # Values can be `'head'` (the default) or `'tail'` for overlay decorations, and From aade50104025731e2eb8848570bef9359b26440b Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 6 Mar 2017 15:29:01 -0700 Subject: [PATCH 095/403] Refactor to unify computations related to tiles --- spec/text-editor-component-spec.js | 6 +-- src/text-editor-component.js | 61 ++++++++++++++++-------------- 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index db82b507708..7e687300a6c 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -527,10 +527,8 @@ function clientLeftForCharacter (component, row, column) { function lineNumberNodeForScreenRow (component, row) { const gutterElement = component.refs.lineNumberGutter.element - const endRow = Math.min(component.getRenderedEndRow(), component.getModel().getApproximateScreenLineCount()) - const visibleTileCount = Math.ceil((endRow - component.getRenderedStartRow()) / component.getRowsPerTile()) - const tileStartRow = component.getTileStartRow(row) - const tileIndex = (tileStartRow / component.getRowsPerTile()) % visibleTileCount + const tileStartRow = component.tileStartRowForRow(row) + const tileIndex = component.tileIndexForTileStartRow(tileStartRow) return gutterElement.children[tileIndex].children[row - tileStartRow] } diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 06e3d5abd60..bd8857ee0a4 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -170,12 +170,12 @@ class TextEditorComponent { if (this.measurements) { const startRow = this.getRenderedStartRow() - const endRow = Math.min(model.getApproximateScreenLineCount(), this.getRenderedEndRow()) - const visibleRowCount = endRow - startRow - const bufferRows = new Array(visibleRowCount) - const foldableFlags = new Array(visibleRowCount) - const softWrappedFlags = new Array(visibleRowCount) - const lineNumberDecorations = new Array(visibleRowCount) + const endRow = this.getRenderedEndRow() + const renderedRowCount = endRow - startRow + const bufferRows = new Array(renderedRowCount) + const foldableFlags = new Array(renderedRowCount) + const softWrappedFlags = new Array(renderedRowCount) + const lineNumberDecorations = new Array(renderedRowCount) let previousBufferRow = (startRow > 0) ? model.bufferRowForScreenRow(startRow - 1) : -1 for (let row = startRow; row < endRow; row++) { @@ -192,6 +192,7 @@ class TextEditorComponent { this.currentFrameLineNumberGutterProps = { ref: 'lineNumberGutter', + parentComponent: this, height: this.getScrollHeight(), width: this.measurements.lineNumberGutterWidth, lineHeight: this.measurements.lineHeight, @@ -258,9 +259,6 @@ class TextEditorComponent { const startRow = this.getRenderedStartRow() const endRow = this.getRenderedEndRow() - // const firstTileStartRow = this.getFirstTileStartRow() - const visibleTileCount = this.getVisibleTileCount() - // const lastTileStartRow = this.getLastTileStartRow() const rowsPerTile = this.getRowsPerTile() const tileHeight = this.measurements.lineHeight * rowsPerTile const tileWidth = this.getContentWidth() @@ -268,14 +266,13 @@ class TextEditorComponent { const displayLayer = this.getModel().displayLayer const screenLines = displayLayer.getScreenLines(startRow, endRow) - const tileNodes = new Array(visibleTileCount) + const tileNodes = new Array(this.getRenderedTileCount()) for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow += rowsPerTile) { - const tileEndRow = tileStartRow + rowsPerTile - const tileHeight = rowsPerTile * this.measurements.lineHeight - const tileIndex = (tileStartRow / rowsPerTile) % visibleTileCount + const tileEndRow = Math.min(endRow, tileStartRow + rowsPerTile) + const tileIndex = this.tileIndexForTileStartRow(tileStartRow) - const lineDecorations = new Array(rowsPerTile) + const lineDecorations = new Array(tileEndRow - tileStartRow) for (let row = tileStartRow; row < tileEndRow; row++) { lineDecorations[row - tileStartRow] = this.decorationsToRender.lines.get(row) } @@ -1024,20 +1021,16 @@ class TextEditorComponent { return this.props.rowsPerTile || DEFAULT_ROWS_PER_TILE } - getTileStartRow (row) { + tileStartRowForRow (row) { return row - (row % this.getRowsPerTile()) } - getVisibleTileCount () { - return Math.floor((this.getLastVisibleRow() - this.getFirstVisibleRow()) / this.getRowsPerTile()) + 2 + tileIndexForTileStartRow (startRow) { + return (startRow / this.getRowsPerTile()) % this.getRenderedTileCount() } getFirstTileStartRow () { - return this.getTileStartRow(this.getFirstVisibleRow()) - } - - getLastTileStartRow () { - return this.getFirstTileStartRow() + ((this.getVisibleTileCount() - 1) * this.getRowsPerTile()) + return this.tileStartRowForRow(this.getFirstVisibleRow()) } getRenderedStartRow () { @@ -1045,7 +1038,14 @@ class TextEditorComponent { } getRenderedEndRow () { - return this.getFirstTileStartRow() + this.getVisibleTileCount() * this.getRowsPerTile() + return Math.min( + this.getModel().getApproximateScreenLineCount(), + this.getFirstTileStartRow() + this.getVisibleTileCount() * this.getRowsPerTile() + ) + } + + getRenderedTileCount () { + return Math.ceil((this.getRenderedEndRow() - this.getRenderedStartRow()) / this.getRowsPerTile()) } getFirstVisibleRow () { @@ -1062,10 +1062,15 @@ class TextEditorComponent { ) } + getVisibleTileCount () { + return Math.floor((this.getLastVisibleRow() - this.getFirstVisibleRow()) / this.getRowsPerTile()) + 2 + } + // Ensure the spatial index is populated with rows that are currently // visible so we *at least* get the longest row in the visible range. populateVisibleRowRange () { - this.getModel().displayLayer.populateSpatialIndexIfNeeded(Infinity, this.getRenderedEndRow()) + const endRow = this.getFirstTileStartRow() + this.getVisibleTileCount() * this.getRowsPerTile() + this.getModel().displayLayer.populateSpatialIndexIfNeeded(Infinity, endRow) } topPixelPositionForRow (row) { @@ -1097,13 +1102,13 @@ class LineNumberGutterComponent { render () { const { - height, width, lineHeight, startRow, endRow, rowsPerTile, + parentComponent, height, width, lineHeight, startRow, endRow, rowsPerTile, maxLineNumberDigits, bufferRows, softWrappedFlags, foldableFlags, lineNumberDecorations } = this.props - const visibleTileCount = Math.ceil((endRow - startRow) / rowsPerTile) - const children = new Array(visibleTileCount) + const renderedTileCount = parentComponent.getRenderedTileCount() + const children = new Array(renderedTileCount) const tileHeight = rowsPerTile * lineHeight + 'px' const tileWidth = width + 'px' @@ -1140,7 +1145,7 @@ class LineNumberGutterComponent { ) } - const tileIndex = (tileStartRow / rowsPerTile) % visibleTileCount + const tileIndex = parentComponent.tileIndexForTileStartRow(tileStartRow) const top = tileStartRow * lineHeight children[tileIndex] = $.div({ From a4224922a3933c93a2827bc914bfb7ad7a95ba3e Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 6 Mar 2017 20:20:51 -0700 Subject: [PATCH 096/403] WIP: Add highlight decorations, but no tests yet --- src/text-editor-component.js | 292 +++++++++++++++++++++++++++++++---- 1 file changed, 258 insertions(+), 34 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index bd8857ee0a4..8494450fdd6 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -40,7 +40,11 @@ class TextEditorComponent { this.cursorsToRender = [] this.decorationsToRender = { lineNumbers: new Map(), - lines: new Map() + lines: new Map(), + highlights: new Map() + } + this.decorationsToMeasure = { + highlights: new Map() } if (this.props.model) this.observeModel() @@ -87,6 +91,7 @@ class TextEditorComponent { if (longestLineToMeasure) this.measureLongestLineWidth(longestLineToMeasure) if (this.pendingAutoscroll) this.finalizeAutoscroll() this.positionCursorsToRender() + this.updateHighlightsToRender() etch.updateSync(this) @@ -276,14 +281,17 @@ class TextEditorComponent { for (let row = tileStartRow; row < tileEndRow; row++) { lineDecorations[row - tileStartRow] = this.decorationsToRender.lines.get(row) } + const highlightDecorations = this.decorationsToRender.highlights.get(tileStartRow) tileNodes[tileIndex] = $(LinesTileComponent, { key: tileIndex, height: tileHeight, width: tileWidth, top: this.topPixelPositionForRow(tileStartRow), + lineHeight: this.measurements.lineHeight, screenLines: screenLines.slice(tileStartRow - startRow, tileEndRow - startRow), lineDecorations, + highlightDecorations, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId @@ -386,29 +394,35 @@ class TextEditorComponent { }) const lastCursorMarker = model.getLastCursor().getMarker() - this.cursorsToRender.length = cursorMarkers.length + this.cursorsToRender.length = 0 this.lastCursorIndex = -1 for (let i = 0; i < cursorMarkers.length; i++) { const cursorMarker = cursorMarkers[i] if (cursorMarker === lastCursorMarker) this.lastCursorIndex = i const screenPosition = cursorMarker.getHeadScreenPosition() const {row, column} = screenPosition + + if (row < this.getRenderedStartRow() || row >= this.getRenderedEndRow()) { + continue + } + this.requestHorizontalMeasurement(row, column) let columnWidth = 0 if (model.lineLengthForScreenRow(row) > column) { columnWidth = 1 this.requestHorizontalMeasurement(row, column + 1) } - this.cursorsToRender[i] = { + this.cursorsToRender.push({ screenPosition, columnWidth, pixelTop: 0, pixelLeft: 0, pixelWidth: 0 - } + }) } } queryDecorationsToRender () { this.decorationsToRender.lineNumbers.clear() this.decorationsToRender.lines.clear() + this.decorationsToMeasure.highlights.clear() const decorationsByMarker = this.getModel().decorationManager.decorationPropertiesByMarkerForScreenRowRange( @@ -435,40 +449,85 @@ class TextEditorComponent { switch (type) { case 'line': case 'line-number': - const decorationsByRow = (type === 'line') ? this.decorationsToRender.lines : this.decorationsToRender.lineNumbers + this.addLineDecorationToRender(type, decoration, screenRange, reversed) + break + case 'highlight': + this.addHighlightDecorationToMeasure(decoration, screenRange) + break + } + } + } - let omitLastRow = false - if (screenRange.isEmpty()) { - if (decoration.onlyNonEmpty) return - } else { - if (decoration.onlyEmpty) return - if (decoration.omitEmptyLastRow !== false) { - omitLastRow = screenRange.end.column === 0 - } - } + addLineDecorationToRender (type, decoration, screenRange, reversed) { + const decorationsByRow = (type === 'line') ? this.decorationsToRender.lines : this.decorationsToRender.lineNumbers + + let omitLastRow = false + if (screenRange.isEmpty()) { + if (decoration.onlyNonEmpty) return + } else { + if (decoration.onlyEmpty) return + if (decoration.omitEmptyLastRow !== false) { + omitLastRow = screenRange.end.column === 0 + } + } - let startRow = screenRange.start.row - let endRow = screenRange.end.row + let startRow = screenRange.start.row + let endRow = screenRange.end.row - if (decoration.onlyHead) { - if (reversed) { - endRow = startRow - } else { - startRow = endRow - } - } + if (decoration.onlyHead) { + if (reversed) { + endRow = startRow + } else { + startRow = endRow + } + } - for (let row = startRow; row <= endRow; row++) { - if (omitLastRow && row === endRow) break - const currentClassName = decorationsByRow.get(row) - const newClassName = currentClassName ? currentClassName + ' ' + decoration.class : decoration.class - decorationsByRow.set(row, newClassName) - } - break + for (let row = startRow; row <= endRow; row++) { + if (omitLastRow && row === endRow) break + const currentClassName = decorationsByRow.get(row) + const newClassName = currentClassName ? currentClassName + ' ' + decoration.class : decoration.class + decorationsByRow.set(row, newClassName) + } + } + + addHighlightDecorationToMeasure(decoration, screenRange) { + screenRange = constrainRangeToRows(screenRange, this.getRenderedStartRow(), this.getRenderedEndRow()) + if (screenRange.isEmpty()) return + let tileStartRow = this.tileStartRowForRow(screenRange.start.row) + const rowsPerTile = this.getRowsPerTile() + + while (tileStartRow <= screenRange.end.row) { + const tileEndRow = tileStartRow + rowsPerTile + const screenRangeInTile = constrainRangeToRows(screenRange, tileStartRow, tileEndRow) + + let tileHighlights = this.decorationsToMeasure.highlights.get(tileStartRow) + if (!tileHighlights) { + tileHighlights = [] + this.decorationsToMeasure.highlights.set(tileStartRow, tileHighlights) } + tileHighlights.push({decoration, screenRange: screenRangeInTile}) + + this.requestHorizontalMeasurement(screenRangeInTile.start.row, screenRangeInTile.start.column) + this.requestHorizontalMeasurement(screenRangeInTile.end.row, screenRangeInTile.end.column) + + tileStartRow += rowsPerTile } } + updateHighlightsToRender () { + this.decorationsToRender.highlights.clear() + this.decorationsToMeasure.highlights.forEach((highlights, tileRow) => { + for (let i = 0, length = highlights.length; i < length; i++) { + const highlight = highlights[i] + const {start, end} = highlight.screenRange + highlight.startPixelTop = this.pixelTopForScreenRow(start.row) + highlight.startPixelLeft = this.pixelLeftForScreenRowAndColumn(start.row, start.column) + highlight.endPixelTop = this.pixelTopForScreenRow(end.row + 1) + highlight.endPixelLeft = this.pixelLeftForScreenRowAndColumn(end.row, end.column) + } + this.decorationsToRender.highlights.set(tileRow, highlights) + }) + } positionCursorsToRender () { const height = this.measurements.lineHeight + 'px' @@ -862,6 +921,7 @@ class TextEditorComponent { } requestHorizontalMeasurement (row, column) { + if (column === 0) return let columns = this.horizontalPositionsToMeasure.get(row) if (columns == null) { columns = [] @@ -876,6 +936,13 @@ class TextEditorComponent { const screenLine = this.getModel().displayLayer.getScreenLine(row) const lineNode = this.lineNodesByScreenLineId.get(screenLine.id) + + if (!lineNode) { + const error = new Error('Requested measurement of a line that is not currently rendered') + error.metadata = {row, columnsToMeasure} + throw error + } + const textNodes = this.textNodesByScreenLineId.get(screenLine.id) let positionsForLine = this.horizontalPixelPositionsByScreenLineId.get(screenLine.id) if (positionsForLine == null) { @@ -1210,6 +1277,55 @@ class LinesTileComponent { } render () { + const {height, width, top} = this.props + + return $.div( + { + style: { + contain: 'strict', + position: 'absolute', + height: height + 'px', + width: width + 'px', + willChange: 'transform', + transform: `translateY(${top}px)`, + backgroundColor: 'inherit' + } + }, + this.renderHighlights(), + this.renderLines() + ) + + } + + renderHighlights () { + const {top, height, width, lineHeight, highlightDecorations} = this.props + + let children = null + if (highlightDecorations) { + const decorationCount = highlightDecorations.length + children = new Array(decorationCount) + for (let i = 0; i < decorationCount; i++) { + const highlightProps = Object.assign( + {parentTileTop: top, lineHeight}, + highlightDecorations[i] + ) + children[i] = $(HighlightComponent, highlightProps) + } + } + + return $.div( + { + style: { + position: 'absolute', + contain: 'strict', + height: height + 'px', + width: width + 'px' + }, + }, children + ) + } + + renderLines () { const { height, width, top, screenLines, lineDecorations, displayLayer, @@ -1235,13 +1351,10 @@ class LinesTileComponent { return $.div({ style: { - contain: 'strict', position: 'absolute', + contain: 'strict', height: height + 'px', width: width + 'px', - willChange: 'transform', - transform: `translateY(${top}px)`, - backgroundColor: 'inherit' } }, children) } @@ -1253,6 +1366,23 @@ class LinesTileComponent { if (oldProps.width !== newProps.width) return true if (!arraysEqual(oldProps.screenLines, newProps.screenLines)) return true if (!arraysEqual(oldProps.lineDecorations, newProps.lineDecorations)) return true + + if (!oldProps.highlightDecorations && newProps.highlightDecorations) return true + if (oldProps.highlightDecorations && !newProps.highlightDecorations) return true + + if (oldProps.highlightDecorations && newProps.highlightDecorations) { + if (oldProps.highlightDecorations.length !== newProps.highlightDecorations.length) return true + + for (let i = 0, length = oldProps.highlightDecorations.length; i < length; i++) { + const oldHighlight = oldProps.highlightDecorations[i] + const newHighlight = newProps.highlightDecorations[i] + if (oldHighlight.decoration.class !== newHighlight.decoration.class) return true + if (oldHighlight.startPixelLeft !== newHighlight.startPixelLeft) return true + if (oldHighlight.endPixelLeft !== newHighlight.endPixelLeft) return true + if (!oldHighlight.screenRange.isEqual(newHighlight.screenRange)) return true + } + } + return false } } @@ -1331,6 +1461,85 @@ class LineComponent { } } +class HighlightComponent { + constructor (props) { + this.props = props + etch.initialize(this) + } + + update (props) { + this.props = props + etch.updateSync(this) + } + + render () { + let {startPixelTop, endPixelTop} = this.props + const { + decoration, screenRange, parentTileTop, lineHeight, + startPixelLeft, endPixelLeft, + } = this.props + startPixelTop -= parentTileTop + endPixelTop -= parentTileTop + + let children + if (screenRange.start.row === screenRange.end.row) { + children = $.div({ + className: 'region', + style: { + position: 'absolute', + boxSizing: 'border-box', + top: startPixelTop + 'px', + left: startPixelLeft + 'px', + width: endPixelLeft - startPixelLeft + 'px', + height: lineHeight + 'px' + } + }) + } else { + children = [] + children.push($.div({ + className: 'region', + style: { + position: 'absolute', + boxSizing: 'border-box', + top: startPixelTop + 'px', + left: startPixelLeft + 'px', + right: 0, + height: lineHeight + 'px' + } + })) + + if (screenRange.end.row - screenRange.start.row > 1) { + children.push($.div({ + className: 'region', + style: { + position: 'absolute', + boxSizing: 'border-box', + top: startPixelTop + lineHeight + 'px', + left: 0, + right: 0, + height: endPixelTop - startPixelTop - (lineHeight * 2) + 'px' + } + })) + } + + children.push($.div({ + className: 'region', + style: { + position: 'absolute', + boxSizing: 'border-box', + top: endPixelTop - lineHeight + 'px', + left: 0, + width: endPixelLeft + 'px', + height: lineHeight + 'px' + } + })) + } + + const className = 'highlight ' + decoration.class + return $.div({className}, children) + } +} + const classNamesByScopeName = new Map() function classNameForScopeName (scopeName) { let classString = classNamesByScopeName.get(scopeName) @@ -1354,3 +1563,18 @@ function arraysEqual(a, b) { } return true } + +function constrainRangeToRows (range, startRow, endRow) { + if (range.start.row < startRow || range.end.row >= endRow) { + range = range.copy() + if (range.start.row < startRow) { + range.start.row = startRow + range.start.column = 0 + } + if (range.end.row >= endRow) { + range.end.row = endRow + range.end.column = 0 + } + } + return range +} From eacf0d8f64d04445d6ad8215840f94b4aaca54c9 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 6 Mar 2017 20:46:42 -0700 Subject: [PATCH 097/403] Decorate cursors via private 'cursor' decoration type This eliminates the need to query the selections marker layer more than once per frame, since it is already queried for highlights and line decorations associated with the selections. --- src/text-editor-component.js | 120 +++++++++++++++-------------------- src/text-editor.coffee | 8 ++- 2 files changed, 57 insertions(+), 71 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 8494450fdd6..ce923bbdb71 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -37,14 +37,15 @@ class TextEditorComponent { this.lastKeydown = null this.lastKeydownBeforeKeypress = null this.openedAccentedCharacterMenu = false - this.cursorsToRender = [] this.decorationsToRender = { lineNumbers: new Map(), lines: new Map(), - highlights: new Map() + highlights: new Map(), + cursors: [] } this.decorationsToMeasure = { - highlights: new Map() + highlights: new Map(), + cursors: [] } if (this.props.model) this.observeModel() @@ -83,15 +84,13 @@ class TextEditorComponent { this.populateVisibleRowRange() const longestLineToMeasure = this.checkForNewLongestLine() this.queryDecorationsToRender() - this.queryCursorsToRender() etch.updateSync(this) this.measureHorizontalPositions() if (longestLineToMeasure) this.measureLongestLineWidth(longestLineToMeasure) if (this.pendingAutoscroll) this.finalizeAutoscroll() - this.positionCursorsToRender() - this.updateHighlightsToRender() + this.updateAbsolutePositionedDecorations() etch.updateSync(this) @@ -324,8 +323,8 @@ class TextEditorComponent { const children = [this.renderHiddenInput()] - for (let i = 0; i < this.cursorsToRender.length; i++) { - const {pixelLeft, pixelTop, pixelWidth} = this.cursorsToRender[i] + for (let i = 0; i < this.decorationsToRender.cursors.length; i++) { + const {pixelLeft, pixelTop, pixelWidth} = this.decorationsToRender.cursors[i] children.push($.div({ className: 'cursor', style: { @@ -349,10 +348,9 @@ class TextEditorComponent { renderHiddenInput () { let top, left - const hiddenInputState = this.getHiddenInputState() - if (hiddenInputState) { - top = hiddenInputState.pixelTop - left = hiddenInputState.pixelLeft + if (this.hiddenInputPosition) { + top = this.hiddenInputPosition.pixelTop + left = this.hiddenInputPosition.pixelLeft } else { top = 0 left = 0 @@ -384,45 +382,11 @@ class TextEditorComponent { }) } - queryCursorsToRender () { - const model = this.getModel() - const cursorMarkers = model.selectionsMarkerLayer.findMarkers({ - intersectsScreenRowRange: [ - this.getRenderedStartRow(), - this.getRenderedEndRow() - 1, - ] - }) - const lastCursorMarker = model.getLastCursor().getMarker() - - this.cursorsToRender.length = 0 - this.lastCursorIndex = -1 - for (let i = 0; i < cursorMarkers.length; i++) { - const cursorMarker = cursorMarkers[i] - if (cursorMarker === lastCursorMarker) this.lastCursorIndex = i - const screenPosition = cursorMarker.getHeadScreenPosition() - const {row, column} = screenPosition - - if (row < this.getRenderedStartRow() || row >= this.getRenderedEndRow()) { - continue - } - - this.requestHorizontalMeasurement(row, column) - let columnWidth = 0 - if (model.lineLengthForScreenRow(row) > column) { - columnWidth = 1 - this.requestHorizontalMeasurement(row, column + 1) - } - this.cursorsToRender.push({ - screenPosition, columnWidth, - pixelTop: 0, pixelLeft: 0, pixelWidth: 0 - }) - } - } - queryDecorationsToRender () { this.decorationsToRender.lineNumbers.clear() this.decorationsToRender.lines.clear() this.decorationsToMeasure.highlights.clear() + this.decorationsToMeasure.cursors.length = 0 const decorationsByMarker = this.getModel().decorationManager.decorationPropertiesByMarkerForScreenRowRange( @@ -435,15 +399,15 @@ class TextEditorComponent { const reversed = marker.isReversed() for (let i = 0, length = decorations.length; i < decorations.length; i++) { const decoration = decorations[i] - this.addDecorationToRender(decoration.type, decoration, screenRange, reversed) + this.addDecorationToRender(decoration.type, decoration, marker, screenRange, reversed) } }) } - addDecorationToRender (type, decoration, screenRange, reversed) { + addDecorationToRender (type, decoration, marker, screenRange, reversed) { if (Array.isArray(type)) { for (let i = 0, length = type.length; i < length; i++) { - this.addDecorationToRender(type[i], decoration, screenRange, reversed) + this.addDecorationToRender(type[i], decoration, marker, screenRange, reversed) } } else { switch (type) { @@ -454,6 +418,9 @@ class TextEditorComponent { case 'highlight': this.addHighlightDecorationToMeasure(decoration, screenRange) break + case 'cursor': + this.addCursorDecorationToMeasure(marker, screenRange) + break } } } @@ -514,6 +481,28 @@ class TextEditorComponent { } } + addCursorDecorationToMeasure (marker, screenRange, reversed) { + const model = this.getModel() + const isLastCursor = model.getLastCursor().getMarker() === marker + const screenPosition = reversed ? screenRange.start : screenRange.end + const {row, column} = screenPosition + + if (row < this.getRenderedStartRow() || row >= this.getRenderedEndRow()) return + + this.requestHorizontalMeasurement(row, column) + let columnWidth = 0 + if (model.lineLengthForScreenRow(row) > column) { + columnWidth = 1 + this.requestHorizontalMeasurement(row, column + 1) + } + this.decorationsToMeasure.cursors.push({screenPosition, columnWidth, isLastCursor}) + } + + updateAbsolutePositionedDecorations () { + this.updateHighlightsToRender() + this.updateCursorsToRender() + } + updateHighlightsToRender () { this.decorationsToRender.highlights.clear() this.decorationsToMeasure.highlights.forEach((highlights, tileRow) => { @@ -529,28 +518,24 @@ class TextEditorComponent { }) } - positionCursorsToRender () { + updateCursorsToRender () { + this.decorationsToRender.cursors.length = 0 + const height = this.measurements.lineHeight + 'px' - for (let i = 0; i < this.cursorsToRender.length; i++) { - const cursorToRender = this.cursorsToRender[i] - const {row, column} = cursorToRender.screenPosition + for (let i = 0; i < this.decorationsToMeasure.cursors.length; i++) { + const cursor = this.decorationsToMeasure.cursors[i] + const {row, column} = cursor.screenPosition const pixelTop = this.pixelTopForScreenRow(row) const pixelLeft = this.pixelLeftForScreenRowAndColumn(row, column) - const pixelRight = (cursorToRender.columnWidth === 0) + const pixelRight = (cursor.columnWidth === 0) ? pixelLeft : this.pixelLeftForScreenRowAndColumn(row, column + 1) const pixelWidth = pixelRight - pixelLeft - cursorToRender.pixelTop = pixelTop - cursorToRender.pixelLeft = pixelLeft - cursorToRender.pixelWidth = pixelWidth - } - } - - getHiddenInputState () { - if (this.lastCursorIndex >= 0) { - return this.cursorsToRender[this.lastCursorIndex] + const cursorPosition = {pixelTop, pixelLeft, pixelWidth} + this.decorationsToRender.cursors[i] = cursorPosition + if (cursor.isLastCursor) this.hiddenInputPosition = cursorPosition } } @@ -608,10 +593,9 @@ class TextEditorComponent { // Restore the previous position of the field now that it is already focused // and won't cause unwanted scrolling. - const currentHiddenInputState = this.getHiddenInputState() - if (currentHiddenInputState) { - hiddenInput.style.top = currentHiddenInputState.pixelTop + 'px' - hiddenInput.style.left = currentHiddenInputState.pixelLeft + 'px' + if (this.hiddenInputPosition) { + hiddenInput.style.top = this.hiddenInputPosition.pixelTop + 'px' + hiddenInput.style.left = this.hiddenInputPosition.pixelLeft + 'px' } else { hiddenInput.style.top = 0 hiddenInput.style.left = 0 diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 540e1d2fddc..b1d281abb88 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -199,6 +199,11 @@ class TextEditor extends Model @selectionsMarkerLayer.trackDestructionInOnDidCreateMarkerCallbacks = true @decorationManager = new DecorationManager(@displayLayer) + @decorateMarkerLayer(@selectionsMarkerLayer, type: 'cursor') + @decorateMarkerLayer(@selectionsMarkerLayer, type: 'line-number', class: 'cursor-line') + @decorateMarkerLayer(@selectionsMarkerLayer, type: 'line-number', class: 'cursor-line-no-selection', onlyHead: true, onlyEmpty: true) + @decorateMarkerLayer(@selectionsMarkerLayer, type: 'line', class: 'cursor-line', onlyEmpty: true) + @decorateMarkerLayer(@displayLayer.foldsMarkerLayer, {type: 'line-number', class: 'folded'}) for marker in @selectionsMarkerLayer.getMarkers() @@ -2282,9 +2287,6 @@ class TextEditor extends Model cursor = new Cursor(editor: this, marker: marker, showCursorOnSelection: @showCursorOnSelection) @cursors.push(cursor) @cursorsByMarkerId.set(marker.id, cursor) - @decorateMarker(marker, type: 'line-number', class: 'cursor-line') - @decorateMarker(marker, type: 'line-number', class: 'cursor-line-no-selection', onlyHead: true, onlyEmpty: true) - @decorateMarker(marker, type: 'line', class: 'cursor-line', onlyEmpty: true) cursor moveCursors: (fn) -> From 3101e284590523f2f9b9e5657a15d686819ec39b Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 6 Mar 2017 20:53:18 -0700 Subject: [PATCH 098/403] Constrain line/line number decoration update to rendered rows --- src/text-editor-component.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index ce923bbdb71..e72ae4a6567 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -449,8 +449,11 @@ class TextEditorComponent { } } + startRow = Math.max(startRow, this.getRenderedStartRow()) + endRow = Math.min(endRow, this.getRenderedEndRow() - 1) + for (let row = startRow; row <= endRow; row++) { - if (omitLastRow && row === endRow) break + if (omitLastRow && row === screenRange.end.row) break const currentClassName = decorationsByRow.get(row) const newClassName = currentClassName ? currentClassName + ' ' + decoration.class : decoration.class decorationsByRow.set(row, newClassName) From 003f6ff2314ad2483c6999be2a7d7af957d87f2d Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 6 Mar 2017 20:58:43 -0700 Subject: [PATCH 099/403] Add test for off-screen cursors of selections intersecting rendered rows We should not attempt to render these cursors even though part of their associated selection is visible. --- spec/text-editor-component-spec.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 7e687300a6c..9c9bff93d0f 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -145,6 +145,11 @@ describe('TextEditorComponent', () => { cursorNodes = Array.from(element.querySelectorAll('.cursor')) expect(cursorNodes.length).toBe(0) + + editor.setSelectedScreenRange([[8, 0], [12, 0]], {autoscroll: false}) + await component.getNextUpdatePromise() + cursorNodes = Array.from(element.querySelectorAll('.cursor')) + expect(cursorNodes.length).toBe(0) }) it('places the hidden input element at the location of the last cursor if it is visible', async () => { From c80dbbce3c0cf0c74bd5b06d6aca0786bc48b6a1 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 6 Mar 2017 21:15:00 -0700 Subject: [PATCH 100/403] Add tests for highlight rendering --- spec/text-editor-component-spec.js | 94 ++++++++++++++++++++++++++++++ src/text-editor-component.js | 26 +++++---- 2 files changed, 108 insertions(+), 12 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 9c9bff93d0f..e44dac272fb 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -473,6 +473,100 @@ describe('TextEditorComponent', () => { expect(lineNodeForScreenRow(component, 3).classList.contains('b')).toBe(true) }) }) + + describe('highlight decorations', () => { + it('renders single-line highlights', async () => { + const {component, element, editor} = buildComponent() + const marker = editor.markScreenRange([[1, 2], [1, 10]]) + editor.decorateMarker(marker, {type: 'highlight', class: 'a'}) + await component.getNextUpdatePromise() + + { + const regions = element.querySelectorAll('.highlight.a .region') + expect(regions.length).toBe(1) + const regionRect = regions[0].getBoundingClientRect() + expect(regionRect.top).toBe(lineNodeForScreenRow(component, 1).getBoundingClientRect().top) + expect(Math.round(regionRect.left)).toBe(clientLeftForCharacter(component, 1, 2)) + expect(Math.round(regionRect.right)).toBe(clientLeftForCharacter(component, 1, 10)) + } + + marker.setScreenRange([[1, 4], [1, 8]]) + await component.getNextUpdatePromise() + + { + const regions = element.querySelectorAll('.highlight.a .region') + expect(regions.length).toBe(1) + const regionRect = regions[0].getBoundingClientRect() + expect(regionRect.top).toBe(lineNodeForScreenRow(component, 1).getBoundingClientRect().top) + expect(regionRect.bottom).toBe(lineNodeForScreenRow(component, 1).getBoundingClientRect().bottom) + expect(Math.round(regionRect.left)).toBe(clientLeftForCharacter(component, 1, 4)) + expect(Math.round(regionRect.right)).toBe(clientLeftForCharacter(component, 1, 8)) + } + }) + + it('renders multi-line highlights that span across tiles', async () => { + const {component, element, editor} = buildComponent({rowsPerTile: 3}) + const marker = editor.markScreenRange([[2, 4], [3, 4]]) + editor.decorateMarker(marker, {type: 'highlight', class: 'a'}) + + await component.getNextUpdatePromise() + + { + // We have 2 top-level highlight divs due to the regions being split + // across 2 different tiles + expect(element.querySelectorAll('.highlight.a').length).toBe(2) + + const regions = element.querySelectorAll('.highlight.a .region') + expect(regions.length).toBe(2) + const region0Rect = regions[0].getBoundingClientRect() + expect(region0Rect.top).toBe(lineNodeForScreenRow(component, 2).getBoundingClientRect().top) + expect(region0Rect.bottom).toBe(lineNodeForScreenRow(component, 2).getBoundingClientRect().bottom) + expect(Math.round(region0Rect.left)).toBe(clientLeftForCharacter(component, 2, 4)) + expect(Math.round(region0Rect.right)).toBe(component.refs.content.getBoundingClientRect().right) + + const region1Rect = regions[1].getBoundingClientRect() + expect(region1Rect.top).toBe(lineNodeForScreenRow(component, 3).getBoundingClientRect().top) + expect(region1Rect.bottom).toBe(lineNodeForScreenRow(component, 3).getBoundingClientRect().bottom) + expect(Math.round(region1Rect.left)).toBe(clientLeftForCharacter(component, 3, 0)) + expect(Math.round(region1Rect.right)).toBe(clientLeftForCharacter(component, 3, 4)) + } + + marker.setScreenRange([[2, 4], [5, 4]]) + await component.getNextUpdatePromise() + + { + // Still split across 2 tiles + expect(element.querySelectorAll('.highlight.a').length).toBe(2) + + const regions = element.querySelectorAll('.highlight.a .region') + expect(regions.length).toBe(4) // Each tile renders its + + const region0Rect = regions[0].getBoundingClientRect() + expect(region0Rect.top).toBe(lineNodeForScreenRow(component, 2).getBoundingClientRect().top) + expect(region0Rect.bottom).toBe(lineNodeForScreenRow(component, 2).getBoundingClientRect().bottom) + expect(Math.round(region0Rect.left)).toBe(clientLeftForCharacter(component, 2, 4)) + expect(Math.round(region0Rect.right)).toBe(component.refs.content.getBoundingClientRect().right) + + const region1Rect = regions[1].getBoundingClientRect() + expect(region1Rect.top).toBe(lineNodeForScreenRow(component, 3).getBoundingClientRect().top) + expect(region1Rect.bottom).toBe(lineNodeForScreenRow(component, 4).getBoundingClientRect().top) + expect(Math.round(region1Rect.left)).toBe(component.refs.content.getBoundingClientRect().left) + expect(Math.round(region1Rect.right)).toBe(component.refs.content.getBoundingClientRect().right) + + const region2Rect = regions[2].getBoundingClientRect() + expect(region2Rect.top).toBe(lineNodeForScreenRow(component, 4).getBoundingClientRect().top) + expect(region2Rect.bottom).toBe(lineNodeForScreenRow(component, 5).getBoundingClientRect().top) + expect(Math.round(region2Rect.left)).toBe(component.refs.content.getBoundingClientRect().left) + expect(Math.round(region2Rect.right)).toBe(component.refs.content.getBoundingClientRect().right) + + const region3Rect = regions[3].getBoundingClientRect() + expect(region3Rect.top).toBe(lineNodeForScreenRow(component, 5).getBoundingClientRect().top) + expect(region3Rect.bottom).toBe(lineNodeForScreenRow(component, 5).getBoundingClientRect().bottom) + expect(Math.round(region3Rect.left)).toBe(component.refs.content.getBoundingClientRect().left) + expect(Math.round(region3Rect.right)).toBe(clientLeftForCharacter(component, 5, 4)) + } + }) + }) }) function buildComponent (params = {}) { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index e72ae4a6567..491c826571b 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -253,7 +253,7 @@ class TextEditorComponent { ) } - return $.div({style}, children) + return $.div({ref: 'content', style}, children) } renderLineTiles (width, height) { @@ -1509,17 +1509,19 @@ class HighlightComponent { })) } - children.push($.div({ - className: 'region', - style: { - position: 'absolute', - boxSizing: 'border-box', - top: endPixelTop - lineHeight + 'px', - left: 0, - width: endPixelLeft + 'px', - height: lineHeight + 'px' - } - })) + if (endPixelLeft > 0) { + children.push($.div({ + className: 'region', + style: { + position: 'absolute', + boxSizing: 'border-box', + top: endPixelTop - lineHeight + 'px', + left: 0, + width: endPixelLeft + 'px', + height: lineHeight + 'px' + } + })) + } } const className = 'highlight ' + decoration.class From 0a9ecd53699a8d9ab991cfa9c449c518feb2890a Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 7 Mar 2017 20:31:45 -0700 Subject: [PATCH 101/403] :arrow_up: etch --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e1d4a4797c9..72eacad3442 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dedent": "^0.6.0", "devtron": "1.3.0", "element-resize-detector": "^1.1.10", - "etch": "^0.9.2", + "etch": "^0.9.5", "event-kit": "^2.3.0", "find-parent-dir": "^0.3.0", "first-mate": "7.0.4", From 9a38e8c0d15ac69ab69dd377359080f586e34f0d Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 7 Mar 2017 20:45:50 -0700 Subject: [PATCH 102/403] Support scrollPastEnd option --- spec/text-editor-component-spec.js | 36 +++++++++++++++++++++++++++--- src/text-editor-component.js | 31 +++++++++++++++++++------ src/text-editor.coffee | 8 +++---- 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index e44dac272fb..10043a185c8 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -86,6 +86,31 @@ describe('TextEditorComponent', () => { // TODO: Confirm that we'll update this value as indexing proceeds }) + it('honors the scrollPastEnd option by adding empty space equivalent to the clientHeight to the end of the content area', async () => { + const {component, element, editor} = buildComponent() + const {scroller} = component.refs + + await editor.update({scrollPastEnd: true}) + await setEditorHeightInLines(component, 6) + + // scroll to end + scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight + await component.getNextUpdatePromise() + expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() - 3) + + editor.update({scrollPastEnd: false}) + await component.getNextUpdatePromise() // wait for scrollable content resize + await component.getNextUpdatePromise() // wait for async scroll event due to scrollbar shrinking + expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() - 6) + + // Always allows at least 3 lines worth of overscroll if the editor is short + await setEditorHeightInLines(component, 2) + await editor.update({scrollPastEnd: true}) + scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight + await component.getNextUpdatePromise() + expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() + 1) + }) + it('gives the line number gutter an explicit width and height so its layout can be strictly contained', () => { const {component, element, editor} = buildComponent({rowsPerTile: 3}) @@ -187,7 +212,7 @@ describe('TextEditorComponent', () => { ' right = [];' ) - await setBaseCharacterWidth(component, 45) + await setEditorWidthInCharacters(component, 45) expect(lineNodeForScreenRow(component, 3).textContent).toBe( ' var pivot = items.shift(), current, left ' ) @@ -331,7 +356,7 @@ describe('TextEditorComponent', () => { it('does not horizontally autoscroll by more than half of the visible "base-width" characters if the editor is narrower than twice the scroll margin', async () => { const {component, element, editor} = buildComponent() const {scroller, gutterContainer} = component.refs - await setBaseCharacterWidth(component, 1.5 * editor.horizontalScrollMargin) + await setEditorWidthInCharacters(component, 1.5 * editor.horizontalScrollMargin) const contentWidth = scroller.clientWidth - gutterContainer.offsetWidth const contentWidthInCharacters = Math.floor(contentWidth / component.measurements.baseCharacterWidth) @@ -591,7 +616,12 @@ function getBaseCharacterWidth (component) { ) } -async function setBaseCharacterWidth (component, widthInCharacters) { +async function setEditorHeightInLines(component, heightInLines) { + component.element.style.height = component.measurements.lineHeight * heightInLines + 'px' + await component.getNextUpdatePromise() +} + +async function setEditorWidthInCharacters (component, widthInCharacters) { component.element.style.width = component.getGutterContainerWidth() + widthInCharacters * component.measurements.baseCharacterWidth + diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 491c826571b..aae13af36ef 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -31,7 +31,7 @@ class TextEditorComponent { this.textNodesByScreenLineId = new Map() this.pendingAutoscroll = null this.autoscrollTop = null - this.contentWidthOrHeightChanged = false + this.contentDimensionsChanged = false this.previousScrollWidth = 0 this.previousScrollHeight = 0 this.lastKeydown = null @@ -60,6 +60,8 @@ class TextEditorComponent { } scheduleUpdate () { + if (!this.visible) return + if (this.updatedSynchronously) { this.updateSync() } else if (!this.updateScheduled) { @@ -79,7 +81,7 @@ class TextEditorComponent { } this.horizontalPositionsToMeasure.clear() - if (this.contentWidthOrHeightChanged) this.measureClientDimensions() + if (this.contentDimensionsChanged) this.measureClientDimensions() if (this.pendingAutoscroll) this.initiateAutoscroll() this.populateVisibleRowRange() const longestLineToMeasure = this.checkForNewLongestLine() @@ -175,7 +177,7 @@ class TextEditorComponent { if (this.measurements) { const startRow = this.getRenderedStartRow() const endRow = this.getRenderedEndRow() - const renderedRowCount = endRow - startRow + const renderedRowCount = this.getRenderedRowCount() const bufferRows = new Array(renderedRowCount) const foldableFlags = new Array(renderedRowCount) const softWrappedFlags = new Array(renderedRowCount) @@ -231,7 +233,7 @@ class TextEditorComponent { const contentWidth = this.getContentWidth() const scrollHeight = this.getScrollHeight() if (contentWidth !== this.previousScrollWidth || scrollHeight !== this.previousScrollHeight) { - this.contentWidthOrHeightChanged = true + this.contentDimensionsChanged = true this.previousScrollWidth = contentWidth this.previousScrollHeight = scrollHeight } @@ -866,7 +868,7 @@ class TextEditorComponent { this.getModel().setWidth(clientWidth - this.getGutterContainerWidth(), true) clientDimensionsChanged = true } - this.contentWidthOrHeightChanged = false + this.contentDimensionsChanged = false return clientDimensionsChanged } @@ -1006,6 +1008,7 @@ class TextEditorComponent { observeModel () { const {model} = this.props + model.component = this const scheduleUpdate = this.scheduleUpdate.bind(this) this.disposables.add(model.selectionsMarkerLayer.onDidUpdate(scheduleUpdate)) this.disposables.add(model.displayLayer.onDidChangeSync(scheduleUpdate)) @@ -1044,7 +1047,17 @@ class TextEditorComponent { } getScrollHeight () { - return this.getModel().getApproximateScreenLineCount() * this.measurements.lineHeight + const model = this.getModel() + const contentHeight = model.getApproximateScreenLineCount() * this.measurements.lineHeight + if (model.getScrollPastEnd()) { + const extraScrollHeight = Math.max( + 3 * this.measurements.lineHeight, + this.getClientHeight() - 3 * this.measurements.lineHeight + ) + return contentHeight + extraScrollHeight + } else { + return contentHeight + } } getScrollWidth () { @@ -1098,8 +1111,12 @@ class TextEditorComponent { ) } + getRenderedRowCount () { + return Math.max(0, this.getRenderedEndRow() - this.getRenderedStartRow()) + } + getRenderedTileCount () { - return Math.ceil((this.getRenderedEndRow() - this.getRenderedStartRow()) / this.getRowsPerTile()) + return Math.ceil(this.getRenderedRowCount() / this.getRowsPerTile()) } getFirstVisibleRow () { diff --git a/src/text-editor.coffee b/src/text-editor.coffee index b1d281abb88..4b1a510e272 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -344,7 +344,7 @@ class TextEditor extends Model when 'scrollPastEnd' if value isnt @scrollPastEnd @scrollPastEnd = value - @presenter?.didChangeScrollPastEnd() + @component?.scheduleUpdate() when 'autoHeight' if value isnt @autoHeight @@ -367,8 +367,8 @@ class TextEditor extends Model @displayLayer.reset(displayLayerParams) - if @editorElement? - @editorElement.views.getNextUpdatePromise() + if @component? + @component.getNextUpdatePromise() else Promise.resolve() @@ -3541,7 +3541,7 @@ class TextEditor extends Model # Get the Element for the editor. getElement: -> - @component ?= new TextEditorComponent({model: this}) + new TextEditorComponent({model: this}) @component.element # Essential: Retrieves the greyed out placeholder of a mini editor. From 00933c7c637f374c7c20d4e23d59781d95a15810 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 7 Mar 2017 22:24:48 -0700 Subject: [PATCH 103/403] Handle IME input --- src/text-editor-component.js | 53 ++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index aae13af36ef..271c3e74fbf 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -36,7 +36,7 @@ class TextEditorComponent { this.previousScrollHeight = 0 this.lastKeydown = null this.lastKeydownBeforeKeypress = null - this.openedAccentedCharacterMenu = false + this.accentedCharacterMenuIsOpen = false this.decorationsToRender = { lineNumbers: new Map(), lines: new Map(), @@ -368,7 +368,10 @@ class TextEditorComponent { textInput: this.didTextInput, keydown: this.didKeydown, keyup: this.didKeyup, - keypress: this.didKeypress + keypress: this.didKeypress, + compositionstart: this.didCompositionStart, + compositionupdate: this.didCompositionUpdate, + compositionend: this.didCompositionEnd }, tabIndex: -1, style: { @@ -643,17 +646,12 @@ class TextEditorComponent { // to test. if (event.data !== ' ') event.preventDefault() + // TODO: Deal with disabled input // if (!this.isInputEnabled()) return - // Workaround of the accented character suggestion feature in macOS. This - // will only occur when the user is not composing in IME mode. When the user - // selects a modified character from the macOS menu, `textInput` will occur - // twice, once for the initial character, and once for the modified - // character. However, only a single keypress will have fired. If this is - // the case, select backward to replace the original character. - if (this.openedAccentedCharacterMenu) { - this.getModel().selectLeft() - this.openedAccentedCharacterMenu = false + if (this.compositionCheckpoint) { + this.getModel().revertToCheckpoint(this.compositionCheckpoint) + this.compositionCheckpoint = null } this.getModel().insertText(event.data, {groupUndo: true}) @@ -678,7 +676,8 @@ class TextEditorComponent { didKeydown (event) { if (this.lastKeydownBeforeKeypress != null) { if (this.lastKeydownBeforeKeypress.keyCode === event.keyCode) { - this.openedAccentedCharacterMenu = true + this.accentedCharacterMenuIsOpen = true + this.getModel().selectLeft() } this.lastKeydownBeforeKeypress = null } else { @@ -686,20 +685,44 @@ class TextEditorComponent { } } - didKeypress () { + didKeypress (event) { this.lastKeydownBeforeKeypress = this.lastKeydown this.lastKeydown = null // This cancels the accented character behavior if we type a key normally // with the menu open. - this.openedAccentedCharacterMenu = false + this.accentedCharacterMenuIsOpen = false } - didKeyup () { + didKeyup (event) { this.lastKeydownBeforeKeypress = null this.lastKeydown = null } + // The IME composition events work like this: + // + // User types 's', chromium pops up the completion helper + // 1. compositionstart fired + // 2. compositionupdate fired; event.data == 's' + // User hits arrow keys to move around in completion helper + // 3. compositionupdate fired; event.data == 's' for each arry key press + // User escape to cancel + // 4. compositionend fired + // OR User chooses a completion + // 4. compositionend fired + // 5. textInput fired; event.data == the completion string + didCompositionStart (event) { + this.compositionCheckpoint = this.getModel().createCheckpoint() + } + + didCompositionUpdate (event) { + this.getModel().insertText(event.data, {select: true}) + } + + didCompositionEnd (event) { + event.target.value = '' + } + didRequestAutoscroll (autoscroll) { this.pendingAutoscroll = autoscroll this.scheduleUpdate() From ec9115e749714d63126ff25c3109b17a0b644695 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 7 Mar 2017 22:30:47 -0700 Subject: [PATCH 104/403] Skip un-accented character when undoing after using press-and-hold menu --- src/text-editor-component.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 271c3e74fbf..315647aa884 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -654,6 +654,12 @@ class TextEditorComponent { this.compositionCheckpoint = null } + // Undo insertion of the original non-accented character so it is discarded + // from the history and does not reappear on undo + if (this.accentedCharacterMenuIsOpen) { + this.getModel().undo() + } + this.getModel().insertText(event.data, {groupUndo: true}) } From d7e76d9302199d7d853ee47fb1ebb8904068bf21 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 8 Mar 2017 19:41:56 -0700 Subject: [PATCH 105/403] Remove unused event parameters --- src/text-editor-component.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 315647aa884..bd7dc2c94a5 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -691,7 +691,7 @@ class TextEditorComponent { } } - didKeypress (event) { + didKeypress () { this.lastKeydownBeforeKeypress = this.lastKeydown this.lastKeydown = null @@ -700,7 +700,7 @@ class TextEditorComponent { this.accentedCharacterMenuIsOpen = false } - didKeyup (event) { + didKeyup () { this.lastKeydownBeforeKeypress = null this.lastKeydown = null } @@ -717,7 +717,7 @@ class TextEditorComponent { // OR User chooses a completion // 4. compositionend fired // 5. textInput fired; event.data == the completion string - didCompositionStart (event) { + didCompositionStart () { this.compositionCheckpoint = this.getModel().createCheckpoint() } From 88f3a5b468f83a95c3e718058f6feb084ac13f62 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 8 Mar 2017 21:00:55 -0700 Subject: [PATCH 106/403] WIP: Port screenPositionForPixelPosition from old LinesYardstick Still need to port tests. This will support various mouse interactions. --- src/text-editor-component.js | 136 ++++++++++++++++++++++++++++------- 1 file changed, 111 insertions(+), 25 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index bd7dc2c94a5..9835e8782cd 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1,8 +1,10 @@ const etch = require('etch') const {CompositeDisposable} = require('event-kit') -const $ = etch.dom -const TextEditorElement = require('./text-editor-element') +const {Point} = require('text-buffer') const resizeDetector = require('element-resize-detector')({strategy: 'scroll'}) +const TextEditorElement = require('./text-editor-element') +const {isPairedCharacter} = require('./text-utils') +const $ = etch.dom const DEFAULT_ROWS_PER_TILE = 6 const NORMAL_WIDTH_CHARACTER = 'x' @@ -311,11 +313,15 @@ class TextEditorComponent { return $.div({ key: 'lineTiles', + ref: 'lineTiles', className: 'lines', style: { position: 'absolute', contain: 'strict', width, height + }, + on: { + mousedown: this.didMouseDownOnLines } }, tileNodes) } @@ -517,10 +523,10 @@ class TextEditorComponent { for (let i = 0, length = highlights.length; i < length; i++) { const highlight = highlights[i] const {start, end} = highlight.screenRange - highlight.startPixelTop = this.pixelTopForScreenRow(start.row) - highlight.startPixelLeft = this.pixelLeftForScreenRowAndColumn(start.row, start.column) - highlight.endPixelTop = this.pixelTopForScreenRow(end.row + 1) - highlight.endPixelLeft = this.pixelLeftForScreenRowAndColumn(end.row, end.column) + highlight.startPixelTop = this.pixelTopForRow(start.row) + highlight.startPixelLeft = this.pixelLeftForRowAndColumn(start.row, start.column) + highlight.endPixelTop = this.pixelTopForRow(end.row + 1) + highlight.endPixelLeft = this.pixelLeftForRowAndColumn(end.row, end.column) } this.decorationsToRender.highlights.set(tileRow, highlights) }) @@ -534,11 +540,11 @@ class TextEditorComponent { const cursor = this.decorationsToMeasure.cursors[i] const {row, column} = cursor.screenPosition - const pixelTop = this.pixelTopForScreenRow(row) - const pixelLeft = this.pixelLeftForScreenRowAndColumn(row, column) + const pixelTop = this.pixelTopForRow(row) + const pixelLeft = this.pixelLeftForRowAndColumn(row, column) const pixelRight = (cursor.columnWidth === 0) ? pixelLeft - : this.pixelLeftForScreenRowAndColumn(row, column + 1) + : this.pixelLeftForRowAndColumn(row, column + 1) const pixelWidth = pixelRight - pixelLeft const cursorPosition = {pixelTop, pixelLeft, pixelWidth} @@ -729,6 +735,18 @@ class TextEditorComponent { event.target.value = '' } + didMouseDownOnLines (event) { + console.log(this.screenPositionForMouseEvent(event)) + } + + screenPositionForMouseEvent ({clientX, clientY}) { + const linesRect = this.refs.lineTiles.getBoundingClientRect() + return this.screenPositionForPixelPosition({ + top: clientY - linesRect.top, + left: clientX - linesRect.left + }) + } + didRequestAutoscroll (autoscroll) { this.pendingAutoscroll = autoscroll this.scheduleUpdate() @@ -737,8 +755,8 @@ class TextEditorComponent { initiateAutoscroll () { const {screenRange, options} = this.pendingAutoscroll - const screenRangeTop = this.pixelTopForScreenRow(screenRange.start.row) - const screenRangeBottom = this.pixelTopForScreenRow(screenRange.end.row) + this.measurements.lineHeight + const screenRangeTop = this.pixelTopForRow(screenRange.start.row) + const screenRangeBottom = this.pixelTopForRow(screenRange.end.row) + this.measurements.lineHeight const verticalScrollMargin = this.getVerticalScrollMargin() this.requestHorizontalMeasurement(screenRange.start.row, screenRange.start.column) @@ -790,8 +808,8 @@ class TextEditorComponent { const {screenRange, options} = this.pendingAutoscroll const gutterContainerWidth = this.getGutterContainerWidth() - let left = this.pixelLeftForScreenRowAndColumn(screenRange.start.row, screenRange.start.column) + gutterContainerWidth - let right = this.pixelLeftForScreenRowAndColumn(screenRange.end.row, screenRange.end.column) + gutterContainerWidth + let left = this.pixelLeftForRowAndColumn(screenRange.start.row, screenRange.start.column) + gutterContainerWidth + let right = this.pixelLeftForRowAndColumn(screenRange.end.row, screenRange.end.column) + gutterContainerWidth const desiredScrollLeft = Math.max(0, left - horizontalScrollMargin - gutterContainerWidth) const desiredScrollRight = Math.min(this.getScrollWidth(), right + horizontalScrollMargin) @@ -995,15 +1013,9 @@ class TextEditorComponent { if (nextColumnToMeasure <= textNodeEndColumn) { let clientPixelPosition if (nextColumnToMeasure === textNodeStartColumn) { - const range = getRangeForMeasurement() - range.setStart(textNode, 0) - range.setEnd(textNode, 1) - clientPixelPosition = range.getBoundingClientRect().left + clientPixelPosition = clientRectForRange(textNode, 0, 1).left } else { - const range = getRangeForMeasurement() - range.setStart(textNode, 0) - range.setEnd(textNode, nextColumnToMeasure - textNodeStartColumn) - clientPixelPosition = range.getBoundingClientRect().right + clientPixelPosition = clientRectForRange(textNode, 0, nextColumnToMeasure - textNodeStartColumn).right } if (lineNodeClientLeft === -1) lineNodeClientLeft = lineNode.getBoundingClientRect().left positions.set(nextColumnToMeasure, clientPixelPosition - lineNodeClientLeft) @@ -1016,16 +1028,88 @@ class TextEditorComponent { } } - pixelTopForScreenRow (row) { + pixelTopForRow (row) { return row * this.measurements.lineHeight } - pixelLeftForScreenRowAndColumn (row, column) { + pixelLeftForRowAndColumn (row, column) { if (column === 0) return 0 const screenLine = this.getModel().displayLayer.getScreenLine(row) return this.horizontalPixelPositionsByScreenLineId.get(screenLine.id).get(column) } + screenPositionForPixelPosition({top, left}) { + const model = this.getModel() + + const row = Math.min( + Math.max(0, Math.floor(top / this.measurements.lineHeight)), + model.getApproximateScreenLineCount() - 1 + ) + + const linesClientLeft = this.refs.lineTiles.getBoundingClientRect().left + const targetClientLeft = linesClientLeft + Math.max(0, left) + const screenLine = this.getModel().displayLayer.getScreenLine(row) + const textNodes = this.textNodesByScreenLineId.get(screenLine.id) + + let containingTextNodeIndex + { + let low = 0 + let high = textNodes.length - 1 + while (low <= high) { + const mid = low + ((high - low) >> 1) + const textNode = textNodes[mid] + const textNodeRect = clientRectForRange(textNode, 0, textNode.length) + + if (targetClientLeft < textNodeRect.left) { + high = mid - 1 + containingTextNodeIndex = Math.max(0, mid - 1) + } else if (targetClientLeft > textNodeRect.right) { + low = mid + 1 + containingTextNodeIndex = Math.min(textNodes.length - 1, mid + 1) + } else { + containingTextNodeIndex = mid + break + } + } + } + const containingTextNode = textNodes[containingTextNodeIndex] + let characterIndex = 0 + { + let low = 0 + let high = containingTextNode.length - 1 + while (low <= high) { + const charIndex = low + ((high - low) >> 1) + const nextCharIndex = isPairedCharacter(containingTextNode.textContent, charIndex) + ? charIndex + 2 + : charIndex + 1 + + const rangeRect = clientRectForRange(containingTextNode, charIndex, nextCharIndex) + if (targetClientLeft < rangeRect.left) { + high = charIndex - 1 + characterIndex = Math.max(0, charIndex - 1) + } else if (targetClientLeft > rangeRect.right) { + low = nextCharIndex + characterIndex = Math.min(containingTextNode.textContent.length, nextCharIndex) + } else { + if (targetClientLeft <= ((rangeRect.left + rangeRect.right) / 2)) { + characterIndex = charIndex + } else { + characterIndex = nextCharIndex + } + break + } + } + } + + let textNodeStartColumn = 0 + for (let i = 0; i < containingTextNodeIndex; i++) { + textNodeStartColumn += textNodes[i].length + } + const column = textNodeStartColumn + characterIndex + + return Point(row, column) + } + getModel () { if (!this.props.model) { const TextEditor = require('./text-editor') @@ -1586,9 +1670,11 @@ function classNameForScopeName (scopeName) { } let rangeForMeasurement -function getRangeForMeasurement () { +function clientRectForRange (textNode, startIndex, endIndex) { if (!rangeForMeasurement) rangeForMeasurement = document.createRange() - return rangeForMeasurement + rangeForMeasurement.setStart(textNode, startIndex) + rangeForMeasurement.setEnd(textNode, endIndex) + return rangeForMeasurement.getBoundingClientRect() } function arraysEqual(a, b) { From fab5a9325452356aaf41dde64ec683ea6240e520 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 9 Mar 2017 19:01:18 -0700 Subject: [PATCH 107/403] Set cursor position on single click --- spec/text-editor-component-spec.js | 52 ++++++++++++++++++++++++++++++ src/text-editor-component.js | 6 +++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 10043a185c8..cb86207ea8e 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -592,6 +592,58 @@ describe('TextEditorComponent', () => { } }) }) + + describe('mouse input', () => { + it('positions the cursor on single click', async () => { + const {component, element, editor} = buildComponent() + const {lineHeight, baseCharacterWidth} = component.measurements + + component.didMouseDownOnLines({ + detail: 1, + clientX: clientLeftForCharacter(component, 0, editor.lineLengthForScreenRow(0)) + 1, + clientY: clientTopForLine(component, 0) + lineHeight / 2 + }) + expect(editor.getCursorScreenPosition()).toEqual([0, editor.lineLengthForScreenRow(0)]) + + component.didMouseDownOnLines({ + detail: 1, + clientX: (clientLeftForCharacter(component, 3, 0) + clientLeftForCharacter(component, 3, 1)) / 2, + clientY: clientTopForLine(component, 1) + lineHeight / 2 + }) + expect(editor.getCursorScreenPosition()).toEqual([1, 0]) + + component.didMouseDownOnLines({ + detail: 1, + clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 15)) / 2, + clientY: clientTopForLine(component, 3) + lineHeight / 2 + }) + expect(editor.getCursorScreenPosition()).toEqual([3, 14]) + + component.didMouseDownOnLines({ + detail: 1, + clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 15)) / 2 + 1, + clientY: clientTopForLine(component, 3) + lineHeight / 2 + }) + expect(editor.getCursorScreenPosition()).toEqual([3, 15]) + + editor.getBuffer().setTextInRange([[3, 14], [3, 15]], '🐣') + await component.getNextUpdatePromise() + + component.didMouseDownOnLines({ + detail: 1, + clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 16)) / 2, + clientY: clientTopForLine(component, 3) + lineHeight / 2 + }) + expect(editor.getCursorScreenPosition()).toEqual([3, 14]) + + component.didMouseDownOnLines({ + detail: 1, + clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 16)) / 2 + 1, + clientY: clientTopForLine(component, 3) + lineHeight / 2 + }) + expect(editor.getCursorScreenPosition()).toEqual([3, 16]) + }) + }) }) function buildComponent (params = {}) { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 9835e8782cd..69e03c7607f 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -736,7 +736,11 @@ class TextEditorComponent { } didMouseDownOnLines (event) { - console.log(this.screenPositionForMouseEvent(event)) + const screenPosition = this.screenPositionForMouseEvent(event) + + if (event.detail === 1) { + this.props.model.setCursorScreenPosition(screenPosition) + } } screenPositionForMouseEvent ({clientX, clientY}) { From 2996500d9027804358b8e1a0c746ee99425dabe2 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 9 Mar 2017 19:31:29 -0700 Subject: [PATCH 108/403] Handle double and triple click on lines --- spec/text-editor-component-spec.js | 24 ++++++++++++++++++++++-- src/text-editor-component.js | 13 +++++++++++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index cb86207ea8e..1c2286c9eeb 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -594,9 +594,9 @@ describe('TextEditorComponent', () => { }) describe('mouse input', () => { - it('positions the cursor on single click', async () => { + it('positions the cursor on single-click', async () => { const {component, element, editor} = buildComponent() - const {lineHeight, baseCharacterWidth} = component.measurements + const {lineHeight} = component.measurements component.didMouseDownOnLines({ detail: 1, @@ -643,6 +643,26 @@ describe('TextEditorComponent', () => { }) expect(editor.getCursorScreenPosition()).toEqual([3, 16]) }) + + it('selects words on double-click', () => { + const {component, editor} = buildComponent() + const clientX = clientLeftForCharacter(component, 1, 16) + const clientY = clientTopForLine(component, 1) + + component.didMouseDownOnLines({detail: 1, clientX, clientY}) + component.didMouseDownOnLines({detail: 2, clientX, clientY}) + expect(editor.getSelectedScreenRange()).toEqual([[1, 13], [1, 21]]) + }) + + it('selects lines on triple-click', () => { + const {component, editor} = buildComponent() + const clientX = clientLeftForCharacter(component, 1, 16) + const clientY = clientTopForLine(component, 1) + + component.didMouseDownOnLines({detail: 1, clientX, clientY}) + component.didMouseDownOnLines({detail: 2, clientX, clientY}) + expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [2, 0]]) + }) }) }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 69e03c7607f..be3857b0030 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -736,10 +736,19 @@ class TextEditorComponent { } didMouseDownOnLines (event) { + const {model} = this.props const screenPosition = this.screenPositionForMouseEvent(event) - if (event.detail === 1) { - this.props.model.setCursorScreenPosition(screenPosition) + switch (event.detail) { + case 1: + model.setCursorScreenPosition(screenPosition) + break + case 2: + model.getLastSelection().selectWord({autoscroll: false}) + break + case 3: + model.getLastSelection().selectLine(null, {autoscroll: false}) + break } } From 35753c3a8de275b8b97ae5dda0f5be4a713cbc30 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 9 Mar 2017 20:41:42 -0700 Subject: [PATCH 109/403] Add specs for single-, triple-, and cmd-clicking --- spec/text-editor-component-spec.js | 126 +++++++++++++++++++++++++++-- src/text-editor-component.js | 20 ++++- src/text-editor.coffee | 12 ++- 3 files changed, 148 insertions(+), 10 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 1c2286c9eeb..f39e595cad4 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -646,9 +646,7 @@ describe('TextEditorComponent', () => { it('selects words on double-click', () => { const {component, editor} = buildComponent() - const clientX = clientLeftForCharacter(component, 1, 16) - const clientY = clientTopForLine(component, 1) - + const {clientX, clientY} = clientPositionForCharacter(component, 1, 16) component.didMouseDownOnLines({detail: 1, clientX, clientY}) component.didMouseDownOnLines({detail: 2, clientX, clientY}) expect(editor.getSelectedScreenRange()).toEqual([[1, 13], [1, 21]]) @@ -656,13 +654,122 @@ describe('TextEditorComponent', () => { it('selects lines on triple-click', () => { const {component, editor} = buildComponent() - const clientX = clientLeftForCharacter(component, 1, 16) - const clientY = clientTopForLine(component, 1) - + const {clientX, clientY} = clientPositionForCharacter(component, 1, 16) component.didMouseDownOnLines({detail: 1, clientX, clientY}) component.didMouseDownOnLines({detail: 2, clientX, clientY}) + component.didMouseDownOnLines({detail: 3, clientX, clientY}) expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [2, 0]]) }) + + it('adds or removes cursors when holding cmd or ctrl when single-clicking', () => { + const {component, editor} = buildComponent() + spyOn(component, 'getPlatform').andCallFake(() => mockedPlatform) + + let mockedPlatform = 'darwin' + expect(editor.getCursorScreenPositions()).toEqual([[0, 0]]) + + // add cursor at 1, 16 + component.didMouseDownOnLines( + Object.assign(clientPositionForCharacter(component, 1, 16), { + detail: 1, + metaKey: true + }) + ) + expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]]) + + // remove cursor at 0, 0 + component.didMouseDownOnLines( + Object.assign(clientPositionForCharacter(component, 0, 0), { + detail: 1, + metaKey: true + }) + ) + expect(editor.getCursorScreenPositions()).toEqual([[1, 16]]) + + // cmd-click cursor at 1, 16 but don't remove it because it's the last one + component.didMouseDownOnLines( + Object.assign(clientPositionForCharacter(component, 1, 16), { + detail: 1, + metaKey: true + }) + ) + expect(editor.getCursorScreenPositions()).toEqual([[1, 16]]) + + // cmd-clicking within a selection destroys it + editor.addSelectionForScreenRange([[2, 10], [2, 15]]) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[1, 16], [1, 16]], + [[2, 10], [2, 15]] + ]) + component.didMouseDownOnLines( + Object.assign(clientPositionForCharacter(component, 2, 13), { + detail: 1, + metaKey: true + }) + ) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[1, 16], [1, 16]] + ]) + + // ctrl-click does not add cursors on macOS + component.didMouseDownOnLines( + Object.assign(clientPositionForCharacter(component, 1, 4), { + detail: 1, + ctrlKey: true + }) + ) + expect(editor.getCursorScreenPositions()).toEqual([[1, 4]]) + + mockedPlatform = 'win32' + + // ctrl-click adds cursors on platforms *other* than macOS + component.didMouseDownOnLines( + Object.assign(clientPositionForCharacter(component, 1, 16), { + detail: 1, + ctrlKey: true + }) + ) + expect(editor.getCursorScreenPositions()).toEqual([[1, 4], [1, 16]]) + }) + + it('adds word selections when holding cmd or ctrl when double-clicking', () => { + const {component, editor} = buildComponent() + editor.addCursorAtScreenPosition([1, 16]) + expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]]) + + component.didMouseDownOnLines( + Object.assign(clientPositionForCharacter(component, 1, 16), { + detail: 1, + metaKey: true + }) + ) + component.didMouseDownOnLines( + Object.assign(clientPositionForCharacter(component, 1, 16), { + detail: 2, + metaKey: true + }) + ) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[0, 0], [0, 0]], + [[1, 13], [1, 21]] + ]) + }) + + it('adds line selections when holding cmd or ctrl when triple-clicking', () => { + const {component, editor} = buildComponent() + editor.addCursorAtScreenPosition([1, 16]) + expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]]) + + const {clientX, clientY} = clientPositionForCharacter(component, 1, 16) + component.didMouseDownOnLines({detail: 1, metaKey: true, clientX, clientY}) + component.didMouseDownOnLines({detail: 2, metaKey: true, clientX, clientY}) + component.didMouseDownOnLines({detail: 3, metaKey: true, clientX, clientY}) + + expect(editor.getSelectedScreenRanges()).toEqual([ + [[0, 0], [0, 0]], + [[1, 0], [2, 0]] + ]) + }) }) }) @@ -726,6 +833,13 @@ function clientLeftForCharacter (component, row, column) { } } +function clientPositionForCharacter (component, row, column) { + return { + clientX: clientLeftForCharacter(component, row, column), + clientY: clientTopForLine(component, row) + } +} + function lineNumberNodeForScreenRow (component, row) { const gutterElement = component.refs.lineNumberGutter.element const tileStartRow = component.tileStartRowForRow(row) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index be3857b0030..e8ea3c64c61 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -393,6 +393,11 @@ class TextEditorComponent { }) } + // This is easier to mock + getPlatform () { + return process.platform + } + queryDecorationsToRender () { this.decorationsToRender.lineNumbers.clear() this.decorationsToRender.lines.clear() @@ -739,14 +744,27 @@ class TextEditorComponent { const {model} = this.props const screenPosition = this.screenPositionForMouseEvent(event) + const addOrRemoveSelection = event.metaKey || (event.ctrlKey && this.getPlatform() !== 'darwin') + switch (event.detail) { case 1: - model.setCursorScreenPosition(screenPosition) + if (addOrRemoveSelection) { + const existingSelection = model.getSelectionAtScreenPosition(screenPosition) + if (existingSelection) { + if (model.hasMultipleCursors()) existingSelection.destroy() + } else { + model.addCursorAtScreenPosition(screenPosition) + } + } else { + model.setCursorScreenPosition(screenPosition) + } break case 2: + if (addOrRemoveSelection) model.addCursorAtScreenPosition(screenPosition) model.getLastSelection().selectWord({autoscroll: false}) break case 3: + if (addOrRemoveSelection) model.addCursorAtScreenPosition(screenPosition) model.getLastSelection().selectLine(null, {autoscroll: false}) break } diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 4b1a510e272..c54ad01386f 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -2096,9 +2096,9 @@ class TextEditor extends Model # # Returns the first matched {Cursor} or undefined getCursorAtScreenPosition: (position) -> - for cursor in @cursors - return cursor if cursor.getScreenPosition().isEqual(position) - undefined + if selection = @getSelectionAtScreenPosition(position) + if selection.getHeadScreenPosition().isEqual(position) + selection.cursor # Essential: Get the position of the most recently added cursor in screen # coordinates. @@ -2647,6 +2647,12 @@ class TextEditor extends Model @createLastSelectionIfNeeded() _.last(@selections) + getSelectionAtScreenPosition: (position) -> + debugger if global.debug + markers = @selectionsMarkerLayer.findMarkers(containsScreenPosition: position) + if markers.length > 0 + @cursorsByMarkerId.get(markers[0].id).selection + # Extended: Get current {Selection}s. # # Returns: An {Array} of {Selection}s. From c410309827839f6292f8afb2ed429edbdc1b8d4c Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 10 Mar 2017 09:52:06 -0700 Subject: [PATCH 110/403] Expand selections on shift-click --- spec/text-editor-component-spec.js | 17 +++++++++++++++++ src/text-editor-component.js | 11 ++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index f39e595cad4..7f3978055ef 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -770,6 +770,23 @@ describe('TextEditorComponent', () => { [[1, 0], [2, 0]] ]) }) + + it('expands the last selection on shift-click', () => { + const {component, element, editor} = buildComponent() + + editor.setCursorScreenPosition([2, 18]) + component.didMouseDownOnLines(Object.assign({ + detail: 1, + shiftKey: true + }, clientPositionForCharacter(component, 1, 4))) + expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [2, 18]]) + + component.didMouseDownOnLines(Object.assign({ + detail: 1, + shiftKey: true + }, clientPositionForCharacter(component, 4, 4))) + expect(editor.getSelectedScreenRange()).toEqual([[2, 18], [4, 4]]) + }) }) }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index e8ea3c64c61..a9515d474c3 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -742,11 +742,12 @@ class TextEditorComponent { didMouseDownOnLines (event) { const {model} = this.props + const {detail, ctrlKey, shiftKey, metaKey} = event const screenPosition = this.screenPositionForMouseEvent(event) - const addOrRemoveSelection = event.metaKey || (event.ctrlKey && this.getPlatform() !== 'darwin') + const addOrRemoveSelection = metaKey || (ctrlKey && this.getPlatform() !== 'darwin') - switch (event.detail) { + switch (detail) { case 1: if (addOrRemoveSelection) { const existingSelection = model.getSelectionAtScreenPosition(screenPosition) @@ -756,7 +757,11 @@ class TextEditorComponent { model.addCursorAtScreenPosition(screenPosition) } } else { - model.setCursorScreenPosition(screenPosition) + if (shiftKey) { + model.selectToScreenPosition(screenPosition) + } else { + model.setCursorScreenPosition(screenPosition) + } } break case 2: From 4ef2119ef87c9ccd62067c98b39b57d15158ef5b Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 10 Mar 2017 10:21:39 -0700 Subject: [PATCH 111/403] Inherit background color so line tiles get a solid background --- src/text-editor-component.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index a9515d474c3..c3b6c83d030 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -229,7 +229,8 @@ class TextEditorComponent { let children let style = { contain: 'strict', - overflow: 'hidden' + overflow: 'hidden', + backgroundColor: 'inherit' } if (this.measurements) { const contentWidth = this.getContentWidth() From 8f385377cf3051bc70d9e2eea42592385946efdf Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 10 Mar 2017 10:22:46 -0700 Subject: [PATCH 112/403] Make cursors render above lines --- src/text-editor-component.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index c3b6c83d030..5b25f344293 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -258,7 +258,14 @@ class TextEditorComponent { ) } - return $.div({ref: 'content', style}, children) + return $.div( + { + ref: 'content', + on: {mousedown: this.didMouseDownOnContent}, + style + }, + children + ) } renderLineTiles (width, height) { @@ -320,9 +327,6 @@ class TextEditorComponent { position: 'absolute', contain: 'strict', width, height - }, - on: { - mousedown: this.didMouseDownOnLines } }, tileNodes) } @@ -350,6 +354,7 @@ class TextEditorComponent { style: { position: 'absolute', contain: 'strict', + zIndex: 1, width, height } }, children) @@ -741,7 +746,7 @@ class TextEditorComponent { event.target.value = '' } - didMouseDownOnLines (event) { + didMouseDownOnContent (event) { const {model} = this.props const {detail, ctrlKey, shiftKey, metaKey} = event const screenPosition = this.screenPositionForMouseEvent(event) From 3f4cd5e4382db66d93ab6abda4213c39138bb609 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 10 Mar 2017 10:23:01 -0700 Subject: [PATCH 113/403] Correctly render cursors on reversed selections --- src/text-editor-component.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 5b25f344293..3619d32216c 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -441,7 +441,7 @@ class TextEditorComponent { this.addHighlightDecorationToMeasure(decoration, screenRange) break case 'cursor': - this.addCursorDecorationToMeasure(marker, screenRange) + this.addCursorDecorationToMeasure(marker, screenRange, reversed) break } } From e92cf0fe70b529f294a0206b7e403c99b0c4e7b0 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 10 Mar 2017 10:25:09 -0700 Subject: [PATCH 114/403] Fix event handler method name in specs --- spec/text-editor-component-spec.js | 48 +++++++++++++++--------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 7f3978055ef..54a7a0dc17a 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -598,28 +598,28 @@ describe('TextEditorComponent', () => { const {component, element, editor} = buildComponent() const {lineHeight} = component.measurements - component.didMouseDownOnLines({ + component.didMouseDownOnContent({ detail: 1, clientX: clientLeftForCharacter(component, 0, editor.lineLengthForScreenRow(0)) + 1, clientY: clientTopForLine(component, 0) + lineHeight / 2 }) expect(editor.getCursorScreenPosition()).toEqual([0, editor.lineLengthForScreenRow(0)]) - component.didMouseDownOnLines({ + component.didMouseDownOnContent({ detail: 1, clientX: (clientLeftForCharacter(component, 3, 0) + clientLeftForCharacter(component, 3, 1)) / 2, clientY: clientTopForLine(component, 1) + lineHeight / 2 }) expect(editor.getCursorScreenPosition()).toEqual([1, 0]) - component.didMouseDownOnLines({ + component.didMouseDownOnContent({ detail: 1, clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 15)) / 2, clientY: clientTopForLine(component, 3) + lineHeight / 2 }) expect(editor.getCursorScreenPosition()).toEqual([3, 14]) - component.didMouseDownOnLines({ + component.didMouseDownOnContent({ detail: 1, clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 15)) / 2 + 1, clientY: clientTopForLine(component, 3) + lineHeight / 2 @@ -629,14 +629,14 @@ describe('TextEditorComponent', () => { editor.getBuffer().setTextInRange([[3, 14], [3, 15]], '🐣') await component.getNextUpdatePromise() - component.didMouseDownOnLines({ + component.didMouseDownOnContent({ detail: 1, clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 16)) / 2, clientY: clientTopForLine(component, 3) + lineHeight / 2 }) expect(editor.getCursorScreenPosition()).toEqual([3, 14]) - component.didMouseDownOnLines({ + component.didMouseDownOnContent({ detail: 1, clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 16)) / 2 + 1, clientY: clientTopForLine(component, 3) + lineHeight / 2 @@ -647,17 +647,17 @@ describe('TextEditorComponent', () => { it('selects words on double-click', () => { const {component, editor} = buildComponent() const {clientX, clientY} = clientPositionForCharacter(component, 1, 16) - component.didMouseDownOnLines({detail: 1, clientX, clientY}) - component.didMouseDownOnLines({detail: 2, clientX, clientY}) + component.didMouseDownOnContent({detail: 1, clientX, clientY}) + component.didMouseDownOnContent({detail: 2, clientX, clientY}) expect(editor.getSelectedScreenRange()).toEqual([[1, 13], [1, 21]]) }) it('selects lines on triple-click', () => { const {component, editor} = buildComponent() const {clientX, clientY} = clientPositionForCharacter(component, 1, 16) - component.didMouseDownOnLines({detail: 1, clientX, clientY}) - component.didMouseDownOnLines({detail: 2, clientX, clientY}) - component.didMouseDownOnLines({detail: 3, clientX, clientY}) + component.didMouseDownOnContent({detail: 1, clientX, clientY}) + component.didMouseDownOnContent({detail: 2, clientX, clientY}) + component.didMouseDownOnContent({detail: 3, clientX, clientY}) expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [2, 0]]) }) @@ -669,7 +669,7 @@ describe('TextEditorComponent', () => { expect(editor.getCursorScreenPositions()).toEqual([[0, 0]]) // add cursor at 1, 16 - component.didMouseDownOnLines( + component.didMouseDownOnContent( Object.assign(clientPositionForCharacter(component, 1, 16), { detail: 1, metaKey: true @@ -678,7 +678,7 @@ describe('TextEditorComponent', () => { expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]]) // remove cursor at 0, 0 - component.didMouseDownOnLines( + component.didMouseDownOnContent( Object.assign(clientPositionForCharacter(component, 0, 0), { detail: 1, metaKey: true @@ -687,7 +687,7 @@ describe('TextEditorComponent', () => { expect(editor.getCursorScreenPositions()).toEqual([[1, 16]]) // cmd-click cursor at 1, 16 but don't remove it because it's the last one - component.didMouseDownOnLines( + component.didMouseDownOnContent( Object.assign(clientPositionForCharacter(component, 1, 16), { detail: 1, metaKey: true @@ -701,7 +701,7 @@ describe('TextEditorComponent', () => { [[1, 16], [1, 16]], [[2, 10], [2, 15]] ]) - component.didMouseDownOnLines( + component.didMouseDownOnContent( Object.assign(clientPositionForCharacter(component, 2, 13), { detail: 1, metaKey: true @@ -712,7 +712,7 @@ describe('TextEditorComponent', () => { ]) // ctrl-click does not add cursors on macOS - component.didMouseDownOnLines( + component.didMouseDownOnContent( Object.assign(clientPositionForCharacter(component, 1, 4), { detail: 1, ctrlKey: true @@ -723,7 +723,7 @@ describe('TextEditorComponent', () => { mockedPlatform = 'win32' // ctrl-click adds cursors on platforms *other* than macOS - component.didMouseDownOnLines( + component.didMouseDownOnContent( Object.assign(clientPositionForCharacter(component, 1, 16), { detail: 1, ctrlKey: true @@ -737,13 +737,13 @@ describe('TextEditorComponent', () => { editor.addCursorAtScreenPosition([1, 16]) expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]]) - component.didMouseDownOnLines( + component.didMouseDownOnContent( Object.assign(clientPositionForCharacter(component, 1, 16), { detail: 1, metaKey: true }) ) - component.didMouseDownOnLines( + component.didMouseDownOnContent( Object.assign(clientPositionForCharacter(component, 1, 16), { detail: 2, metaKey: true @@ -761,9 +761,9 @@ describe('TextEditorComponent', () => { expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]]) const {clientX, clientY} = clientPositionForCharacter(component, 1, 16) - component.didMouseDownOnLines({detail: 1, metaKey: true, clientX, clientY}) - component.didMouseDownOnLines({detail: 2, metaKey: true, clientX, clientY}) - component.didMouseDownOnLines({detail: 3, metaKey: true, clientX, clientY}) + component.didMouseDownOnContent({detail: 1, metaKey: true, clientX, clientY}) + component.didMouseDownOnContent({detail: 2, metaKey: true, clientX, clientY}) + component.didMouseDownOnContent({detail: 3, metaKey: true, clientX, clientY}) expect(editor.getSelectedScreenRanges()).toEqual([ [[0, 0], [0, 0]], @@ -775,13 +775,13 @@ describe('TextEditorComponent', () => { const {component, element, editor} = buildComponent() editor.setCursorScreenPosition([2, 18]) - component.didMouseDownOnLines(Object.assign({ + component.didMouseDownOnContent(Object.assign({ detail: 1, shiftKey: true }, clientPositionForCharacter(component, 1, 4))) expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [2, 18]]) - component.didMouseDownOnLines(Object.assign({ + component.didMouseDownOnContent(Object.assign({ detail: 1, shiftKey: true }, clientPositionForCharacter(component, 4, 4))) From 4ef9d385f3b39c3f7fb0ffc0acc3118a24b72df9 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 10 Mar 2017 10:36:51 -0700 Subject: [PATCH 115/403] Add tests for shift-clicking in wordwise and linewise mode --- spec/text-editor-component-spec.js | 32 ++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 54a7a0dc17a..04366bfdb54 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -786,6 +786,38 @@ describe('TextEditorComponent', () => { shiftKey: true }, clientPositionForCharacter(component, 4, 4))) expect(editor.getSelectedScreenRange()).toEqual([[2, 18], [4, 4]]) + + // reorients word-wise selections to keep the word selected regardless of + // where the subsequent shift-click occurs + editor.setCursorScreenPosition([2, 18]) + editor.getLastSelection().selectWord() + component.didMouseDownOnContent(Object.assign({ + detail: 1, + shiftKey: true + }, clientPositionForCharacter(component, 1, 4))) + expect(editor.getSelectedScreenRange()).toEqual([[1, 2], [2, 20]]) + + component.didMouseDownOnContent(Object.assign({ + detail: 1, + shiftKey: true + }, clientPositionForCharacter(component, 3, 11))) + expect(editor.getSelectedScreenRange()).toEqual([[2, 14], [3, 13]]) + + // reorients line-wise selections to keep the word selected regardless of + // where the subsequent shift-click occurs + editor.setCursorScreenPosition([2, 18]) + editor.getLastSelection().selectLine() + component.didMouseDownOnContent(Object.assign({ + detail: 1, + shiftKey: true + }, clientPositionForCharacter(component, 1, 4))) + expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [3, 0]]) + + component.didMouseDownOnContent(Object.assign({ + detail: 1, + shiftKey: true + }, clientPositionForCharacter(component, 3, 11))) + expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [4, 0]]) }) }) }) From 6bfe08e9b02faa6e89fbb3e0a61383c5b426c509 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 10 Mar 2017 14:04:27 -0700 Subject: [PATCH 116/403] Remove cyclic requires --- src/text-editor-component.js | 12 +++++++++--- src/text-editor.coffee | 3 ++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 3619d32216c..52e4f054e5a 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2,10 +2,12 @@ const etch = require('etch') const {CompositeDisposable} = require('event-kit') const {Point} = require('text-buffer') const resizeDetector = require('element-resize-detector')({strategy: 'scroll'}) -const TextEditorElement = require('./text-editor-element') +const TextEditor = require('./text-editor') const {isPairedCharacter} = require('./text-utils') const $ = etch.dom +let TextEditorElement + const DEFAULT_ROWS_PER_TILE = 6 const NORMAL_WIDTH_CHARACTER = 'x' const DOUBLE_WIDTH_CHARACTER = '我' @@ -17,7 +19,12 @@ module.exports = class TextEditorComponent { constructor (props) { this.props = props - this.element = props.element || new TextEditorElement() + if (props.element) { + this.element = props.element + } else { + if (!TextEditorElement) TextEditorElement = require('./text-editor-element') + this.element = new TextEditorElement() + } this.element.initialize(this) this.virtualNode = $('atom-text-editor') this.virtualNode.domNode = this.element @@ -1154,7 +1161,6 @@ class TextEditorComponent { getModel () { if (!this.props.model) { - const TextEditor = require('./text-editor') this.props.model = new TextEditor() this.observeModel() } diff --git a/src/text-editor.coffee b/src/text-editor.coffee index c54ad01386f..b826250f3c1 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -12,7 +12,7 @@ Model = require './model' Selection = require './selection' TextMateScopeSelector = require('first-mate').ScopeSelector GutterContainer = require './gutter-container' -TextEditorComponent = require './text-editor-component' +TextEditorComponent = null {isDoubleWidthCharacter, isHalfWidthCharacter, isKoreanCharacter, isWrapBoundary} = require './text-utils' ZERO_WIDTH_NBSP = '\ufeff' @@ -3547,6 +3547,7 @@ class TextEditor extends Model # Get the Element for the editor. getElement: -> + TextEditorComponent ?= require('./text-editor-component') new TextEditorComponent({model: this}) @component.element From 5594c9d82f26f024895181ddc88d0dd0d396297f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 10 Mar 2017 14:31:54 -0700 Subject: [PATCH 117/403] Expand selections on mouse drag --- spec/text-editor-component-spec.js | 82 ++++++++++++++++++++++++++++++ src/text-editor-component.js | 47 +++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 04366bfdb54..572c42f1fbd 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -819,6 +819,88 @@ describe('TextEditorComponent', () => { }, clientPositionForCharacter(component, 3, 11))) expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [4, 0]]) }) + + it('expands the last selection on drag', () => { + const {component, editor} = buildComponent() + spyOn(component, 'handleMouseDragUntilMouseUp') + + component.didMouseDownOnContent(Object.assign({ + detail: 1, + }, clientPositionForCharacter(component, 1, 4))) + + { + const [didDrag, didStopDragging] = component.handleMouseDragUntilMouseUp.argsForCall[0] + didDrag(clientPositionForCharacter(component, 8, 8)) + expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [8, 8]]) + didDrag(clientPositionForCharacter(component, 4, 8)) + expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [4, 8]]) + didStopDragging() + expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [4, 8]]) + } + + // Click-drag a second selection... selections are not merged until the + // drag stops. + component.didMouseDownOnContent(Object.assign({ + detail: 1, + metaKey: 1, + }, clientPositionForCharacter(component, 8, 8))) + { + const [didDrag, didStopDragging] = component.handleMouseDragUntilMouseUp.argsForCall[1] + didDrag(clientPositionForCharacter(component, 2, 8)) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[1, 4], [4, 8]], + [[2, 8], [8, 8]] + ]) + didDrag(clientPositionForCharacter(component, 6, 8)) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[1, 4], [4, 8]], + [[6, 8], [8, 8]] + ]) + didDrag(clientPositionForCharacter(component, 2, 8)) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[1, 4], [4, 8]], + [[2, 8], [8, 8]] + ]) + didStopDragging() + expect(editor.getSelectedScreenRanges()).toEqual([ + [[1, 4], [8, 8]] + ]) + } + }) + + it('expands the selection word-wise on double-click-drag', () => { + const {component, editor} = buildComponent() + spyOn(component, 'handleMouseDragUntilMouseUp') + + component.didMouseDownOnContent(Object.assign({ + detail: 1, + }, clientPositionForCharacter(component, 1, 4))) + component.didMouseDownOnContent(Object.assign({ + detail: 2, + }, clientPositionForCharacter(component, 1, 4))) + + const [didDrag, didStopDragging] = component.handleMouseDragUntilMouseUp.argsForCall[1] + didDrag(clientPositionForCharacter(component, 0, 8)) + expect(editor.getSelectedScreenRange()).toEqual([[0, 4], [1, 5]]) + didDrag(clientPositionForCharacter(component, 2, 10)) + expect(editor.getSelectedScreenRange()).toEqual([[1, 2], [2, 13]]) + }) + + it('expands the selection line-wise on triple-click-drag', () => { + const {component, editor} = buildComponent() + spyOn(component, 'handleMouseDragUntilMouseUp') + + const tripleClickPosition = clientPositionForCharacter(component, 2, 8) + component.didMouseDownOnContent(Object.assign({detail: 1}, tripleClickPosition)) + component.didMouseDownOnContent(Object.assign({detail: 2}, tripleClickPosition)) + component.didMouseDownOnContent(Object.assign({detail: 3}, tripleClickPosition)) + + const [didDrag, didStopDragging] = component.handleMouseDragUntilMouseUp.argsForCall[2] + didDrag(clientPositionForCharacter(component, 1, 8)) + expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [3, 0]]) + didDrag(clientPositionForCharacter(component, 4, 10)) + expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [5, 0]]) + }) }) }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 52e4f054e5a..02e49ecba4e 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -786,6 +786,53 @@ class TextEditorComponent { model.getLastSelection().selectLine(null, {autoscroll: false}) break } + + this.handleMouseDragUntilMouseUp( + (event) => { + const screenPosition = this.screenPositionForMouseEvent(event) + model.selectToScreenPosition(screenPosition, {suppressSelectionMerge: true, autoscroll: false}) + this.updateSync() + }, + () => { + model.finalizeSelections() + model.mergeIntersectingSelections() + this.updateSync() + } + ) + } + + handleMouseDragUntilMouseUp (didDragCallback, didStopDragging) { + let dragging = false + let lastMousemoveEvent + + const animationFrameLoop = () => { + window.requestAnimationFrame(() => { + if (dragging && this.visible) { + didDragCallback(lastMousemoveEvent) + animationFrameLoop() + } + }) + } + + function didMouseMove (event) { + lastMousemoveEvent = event + if (!dragging) { + dragging = true + animationFrameLoop() + } + } + + function didMouseUp () { + window.removeEventListener('mousemove', didMouseMove) + window.removeEventListener('mouseup', didMouseUp) + if (dragging) { + dragging = false + didStopDragging() + } + } + + window.addEventListener('mousemove', didMouseMove) + window.addEventListener('mouseup', didMouseUp) } screenPositionForMouseEvent ({clientX, clientY}) { From 35ae3fb08f6e3ab7ec52eaab1f2776d1de8300ef Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 10 Mar 2017 15:44:38 -0700 Subject: [PATCH 118/403] Implement autoscroll when mouse is dragged on content --- spec/text-editor-component-spec.js | 59 ++++++++++++++++++++++++ src/text-editor-component.js | 74 +++++++++++++++++++++++++++++- 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 572c42f1fbd..0366c741513 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -901,6 +901,65 @@ describe('TextEditorComponent', () => { didDrag(clientPositionForCharacter(component, 4, 10)) expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [5, 0]]) }) + + it('autoscrolls the content when dragging near the edge of the screen', async () => { + const {component, editor} = buildComponent({width: 200, height: 200}) + const {scroller} = component.refs + spyOn(component, 'handleMouseDragUntilMouseUp') + + let previousScrollTop = 0 + let previousScrollLeft = 0 + function assertScrolledDownAndRight () { + expect(scroller.scrollTop).toBeGreaterThan(previousScrollTop) + previousScrollTop = scroller.scrollTop + expect(scroller.scrollLeft).toBeGreaterThan(previousScrollLeft) + previousScrollLeft = scroller.scrollLeft + } + + function assertScrolledUpAndLeft () { + expect(scroller.scrollTop).toBeLessThan(previousScrollTop) + previousScrollTop = scroller.scrollTop + expect(scroller.scrollLeft).toBeLessThan(previousScrollLeft) + previousScrollLeft = scroller.scrollLeft + } + + component.didMouseDownOnContent({detail: 1, clientX: 100, clientY: 100}) + const [didDrag, didStopDragging] = component.handleMouseDragUntilMouseUp.argsForCall[0] + didDrag({clientX: 199, clientY: 199}) + assertScrolledDownAndRight() + didDrag({clientX: 199, clientY: 199}) + assertScrolledDownAndRight() + didDrag({clientX: 199, clientY: 199}) + assertScrolledDownAndRight() + didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) + assertScrolledUpAndLeft() + didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) + assertScrolledUpAndLeft() + didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) + assertScrolledUpAndLeft() + + // Don't artificially update scroll measurements beyond the minimum or + // maximum possible scroll positions + expect(scroller.scrollTop).toBe(0) + expect(scroller.scrollLeft).toBe(0) + didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) + expect(component.measurements.scrollTop).toBe(0) + expect(scroller.scrollTop).toBe(0) + expect(component.measurements.scrollLeft).toBe(0) + expect(scroller.scrollLeft).toBe(0) + + const maxScrollTop = scroller.scrollHeight - scroller.clientHeight + const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth + scroller.scrollTop = maxScrollTop + scroller.scrollLeft = maxScrollLeft + await component.getNextUpdatePromise() + + didDrag({clientX: 199, clientY: 199}) + didDrag({clientX: 199, clientY: 199}) + didDrag({clientX: 199, clientY: 199}) + expect(component.measurements.scrollTop).toBe(maxScrollTop) + expect(component.measurements.scrollLeft).toBe(maxScrollLeft) + }) }) }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 02e49ecba4e..0a86629b0df 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -14,6 +14,11 @@ const DOUBLE_WIDTH_CHARACTER = '我' const HALF_WIDTH_CHARACTER = 'ハ' const KOREAN_CHARACTER = '세' const NBSP_CHARACTER = '\u00a0' +const MOUSE_DRAG_AUTOSCROLL_MARGIN = 40 + +function scaleMouseDragAutoscrollDelta (delta) { + return Math.pow(delta / 3, 3) / 280 +} module.exports = class TextEditorComponent { @@ -789,6 +794,7 @@ class TextEditorComponent { this.handleMouseDragUntilMouseUp( (event) => { + this.autoscrollOnMouseDrag(event) const screenPosition = this.screenPositionForMouseEvent(event) model.selectToScreenPosition(screenPosition, {suppressSelectionMerge: true, autoscroll: false}) this.updateSync() @@ -835,7 +841,59 @@ class TextEditorComponent { window.addEventListener('mouseup', didMouseUp) } + autoscrollOnMouseDrag ({clientX, clientY}) { + let {top, bottom, left, right} = this.refs.scroller.getBoundingClientRect() + top += MOUSE_DRAG_AUTOSCROLL_MARGIN + bottom -= MOUSE_DRAG_AUTOSCROLL_MARGIN + left += this.getGutterContainerWidth() + MOUSE_DRAG_AUTOSCROLL_MARGIN + right -= MOUSE_DRAG_AUTOSCROLL_MARGIN + + let yDelta, yDirection + if (clientY < top) { + yDelta = top - clientY + yDirection = -1 + } else if (clientY > bottom) { + yDelta = clientY - bottom + yDirection = 1 + } + + let xDelta, xDirection + if (clientX < left) { + xDelta = left - clientX + xDirection = -1 + } else if (clientX > right) { + xDelta = clientX - right + xDirection = 1 + } + + let scrolled = false + if (yDelta != null) { + const scaledDelta = scaleMouseDragAutoscrollDelta(yDelta) * yDirection + const newScrollTop = this.constrainScrollTop(this.measurements.scrollTop + scaledDelta) + if (newScrollTop !== this.measurements.scrollTop) { + this.measurements.scrollTop += scaledDelta + this.refs.scroller.scrollTop += scaledDelta + scrolled = true + } + } + + if (xDelta != null) { + const scaledDelta = scaleMouseDragAutoscrollDelta(xDelta) * xDirection + const newScrollLeft = this.constrainScrollLeft(this.measurements.scrollLeft + scaledDelta) + if (newScrollLeft !== this.measurements.scrollLeft) { + this.measurements.scrollLeft += scaledDelta + this.refs.scroller.scrollLeft += scaledDelta + scrolled = true + } + } + + if (scrolled) this.updateSync() + } + screenPositionForMouseEvent ({clientX, clientY}) { + const scrollerRect = this.refs.scroller.getBoundingClientRect() + clientX = Math.min(scrollerRect.right, Math.max(scrollerRect.left, clientX)) + clientY = Math.min(scrollerRect.bottom, Math.max(scrollerRect.top, clientY)) const linesRect = this.refs.lineTiles.getBoundingClientRect() return this.screenPositionForPixelPosition({ top: clientY - linesRect.top, @@ -871,11 +929,11 @@ class TextEditorComponent { } if (desiredScrollTop != null) { - desiredScrollTop = Math.max(0, Math.min(desiredScrollTop, this.getScrollHeight() - this.getClientHeight())) + desiredScrollTop = this.constrainScrollTop(desiredScrollTop) } if (desiredScrollBottom != null) { - desiredScrollBottom = Math.max(this.getClientHeight(), Math.min(desiredScrollBottom, this.getScrollHeight())) + desiredScrollBottom = this.constrainScrollTop(desiredScrollBottom - this.getClientHeight()) + this.getClientHeight() } if (!options || options.reversed !== false) { @@ -961,6 +1019,18 @@ class TextEditorComponent { return marginInBaseCharacters * baseCharacterWidth } + constrainScrollTop (desiredScrollTop) { + return Math.max( + 0, Math.min(desiredScrollTop, this.getScrollHeight() - this.getClientHeight()) + ) + } + + constrainScrollLeft (desiredScrollLeft) { + return Math.max( + 0, Math.min(desiredScrollLeft, this.getScrollWidth() - this.getClientWidth()) + ) + } + performInitialMeasurements () { this.measurements = {} this.measureGutterDimensions() From 17d579f949a5cfb2b9ace5335cae20759bf2c247 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 10 Mar 2017 20:22:43 -0700 Subject: [PATCH 119/403] Only handle the left mouse button (and middle on Linux) --- spec/text-editor-component-spec.js | 48 ++++++++++++++++++++++-------- src/text-editor-component.js | 8 +++-- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 0366c741513..af10c304a22 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -600,6 +600,7 @@ describe('TextEditorComponent', () => { component.didMouseDownOnContent({ detail: 1, + button: 0, clientX: clientLeftForCharacter(component, 0, editor.lineLengthForScreenRow(0)) + 1, clientY: clientTopForLine(component, 0) + lineHeight / 2 }) @@ -607,6 +608,7 @@ describe('TextEditorComponent', () => { component.didMouseDownOnContent({ detail: 1, + button: 0, clientX: (clientLeftForCharacter(component, 3, 0) + clientLeftForCharacter(component, 3, 1)) / 2, clientY: clientTopForLine(component, 1) + lineHeight / 2 }) @@ -614,6 +616,7 @@ describe('TextEditorComponent', () => { component.didMouseDownOnContent({ detail: 1, + button: 0, clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 15)) / 2, clientY: clientTopForLine(component, 3) + lineHeight / 2 }) @@ -621,6 +624,7 @@ describe('TextEditorComponent', () => { component.didMouseDownOnContent({ detail: 1, + button: 0, clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 15)) / 2 + 1, clientY: clientTopForLine(component, 3) + lineHeight / 2 }) @@ -631,6 +635,7 @@ describe('TextEditorComponent', () => { component.didMouseDownOnContent({ detail: 1, + button: 0, clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 16)) / 2, clientY: clientTopForLine(component, 3) + lineHeight / 2 }) @@ -638,6 +643,7 @@ describe('TextEditorComponent', () => { component.didMouseDownOnContent({ detail: 1, + button: 0, clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 16)) / 2 + 1, clientY: clientTopForLine(component, 3) + lineHeight / 2 }) @@ -647,17 +653,17 @@ describe('TextEditorComponent', () => { it('selects words on double-click', () => { const {component, editor} = buildComponent() const {clientX, clientY} = clientPositionForCharacter(component, 1, 16) - component.didMouseDownOnContent({detail: 1, clientX, clientY}) - component.didMouseDownOnContent({detail: 2, clientX, clientY}) + component.didMouseDownOnContent({detail: 1, button: 0, clientX, clientY}) + component.didMouseDownOnContent({detail: 2, button: 0, clientX, clientY}) expect(editor.getSelectedScreenRange()).toEqual([[1, 13], [1, 21]]) }) it('selects lines on triple-click', () => { const {component, editor} = buildComponent() const {clientX, clientY} = clientPositionForCharacter(component, 1, 16) - component.didMouseDownOnContent({detail: 1, clientX, clientY}) - component.didMouseDownOnContent({detail: 2, clientX, clientY}) - component.didMouseDownOnContent({detail: 3, clientX, clientY}) + component.didMouseDownOnContent({detail: 1, button: 0, clientX, clientY}) + component.didMouseDownOnContent({detail: 2, button: 0, clientX, clientY}) + component.didMouseDownOnContent({detail: 3, button: 0, clientX, clientY}) expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [2, 0]]) }) @@ -672,6 +678,7 @@ describe('TextEditorComponent', () => { component.didMouseDownOnContent( Object.assign(clientPositionForCharacter(component, 1, 16), { detail: 1, + button: 0, metaKey: true }) ) @@ -681,6 +688,7 @@ describe('TextEditorComponent', () => { component.didMouseDownOnContent( Object.assign(clientPositionForCharacter(component, 0, 0), { detail: 1, + button: 0, metaKey: true }) ) @@ -690,6 +698,7 @@ describe('TextEditorComponent', () => { component.didMouseDownOnContent( Object.assign(clientPositionForCharacter(component, 1, 16), { detail: 1, + button: 0, metaKey: true }) ) @@ -704,6 +713,7 @@ describe('TextEditorComponent', () => { component.didMouseDownOnContent( Object.assign(clientPositionForCharacter(component, 2, 13), { detail: 1, + button: 0, metaKey: true }) ) @@ -715,6 +725,7 @@ describe('TextEditorComponent', () => { component.didMouseDownOnContent( Object.assign(clientPositionForCharacter(component, 1, 4), { detail: 1, + button: 0, ctrlKey: true }) ) @@ -726,6 +737,7 @@ describe('TextEditorComponent', () => { component.didMouseDownOnContent( Object.assign(clientPositionForCharacter(component, 1, 16), { detail: 1, + button: 0, ctrlKey: true }) ) @@ -740,12 +752,14 @@ describe('TextEditorComponent', () => { component.didMouseDownOnContent( Object.assign(clientPositionForCharacter(component, 1, 16), { detail: 1, + button: 0, metaKey: true }) ) component.didMouseDownOnContent( Object.assign(clientPositionForCharacter(component, 1, 16), { detail: 2, + button: 0, metaKey: true }) ) @@ -761,9 +775,9 @@ describe('TextEditorComponent', () => { expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]]) const {clientX, clientY} = clientPositionForCharacter(component, 1, 16) - component.didMouseDownOnContent({detail: 1, metaKey: true, clientX, clientY}) - component.didMouseDownOnContent({detail: 2, metaKey: true, clientX, clientY}) - component.didMouseDownOnContent({detail: 3, metaKey: true, clientX, clientY}) + component.didMouseDownOnContent({detail: 1, button: 0, metaKey: true, clientX, clientY}) + component.didMouseDownOnContent({detail: 2, button: 0, metaKey: true, clientX, clientY}) + component.didMouseDownOnContent({detail: 3, button: 0, metaKey: true, clientX, clientY}) expect(editor.getSelectedScreenRanges()).toEqual([ [[0, 0], [0, 0]], @@ -777,12 +791,14 @@ describe('TextEditorComponent', () => { editor.setCursorScreenPosition([2, 18]) component.didMouseDownOnContent(Object.assign({ detail: 1, + button: 0, shiftKey: true }, clientPositionForCharacter(component, 1, 4))) expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [2, 18]]) component.didMouseDownOnContent(Object.assign({ detail: 1, + button: 0, shiftKey: true }, clientPositionForCharacter(component, 4, 4))) expect(editor.getSelectedScreenRange()).toEqual([[2, 18], [4, 4]]) @@ -793,12 +809,14 @@ describe('TextEditorComponent', () => { editor.getLastSelection().selectWord() component.didMouseDownOnContent(Object.assign({ detail: 1, + button: 0, shiftKey: true }, clientPositionForCharacter(component, 1, 4))) expect(editor.getSelectedScreenRange()).toEqual([[1, 2], [2, 20]]) component.didMouseDownOnContent(Object.assign({ detail: 1, + button: 0, shiftKey: true }, clientPositionForCharacter(component, 3, 11))) expect(editor.getSelectedScreenRange()).toEqual([[2, 14], [3, 13]]) @@ -809,12 +827,14 @@ describe('TextEditorComponent', () => { editor.getLastSelection().selectLine() component.didMouseDownOnContent(Object.assign({ detail: 1, + button: 0, shiftKey: true }, clientPositionForCharacter(component, 1, 4))) expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [3, 0]]) component.didMouseDownOnContent(Object.assign({ detail: 1, + button: 0, shiftKey: true }, clientPositionForCharacter(component, 3, 11))) expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [4, 0]]) @@ -826,6 +846,7 @@ describe('TextEditorComponent', () => { component.didMouseDownOnContent(Object.assign({ detail: 1, + button: 0, }, clientPositionForCharacter(component, 1, 4))) { @@ -842,6 +863,7 @@ describe('TextEditorComponent', () => { // drag stops. component.didMouseDownOnContent(Object.assign({ detail: 1, + button: 0, metaKey: 1, }, clientPositionForCharacter(component, 8, 8))) { @@ -874,9 +896,11 @@ describe('TextEditorComponent', () => { component.didMouseDownOnContent(Object.assign({ detail: 1, + button: 0, }, clientPositionForCharacter(component, 1, 4))) component.didMouseDownOnContent(Object.assign({ detail: 2, + button: 0, }, clientPositionForCharacter(component, 1, 4))) const [didDrag, didStopDragging] = component.handleMouseDragUntilMouseUp.argsForCall[1] @@ -891,9 +915,9 @@ describe('TextEditorComponent', () => { spyOn(component, 'handleMouseDragUntilMouseUp') const tripleClickPosition = clientPositionForCharacter(component, 2, 8) - component.didMouseDownOnContent(Object.assign({detail: 1}, tripleClickPosition)) - component.didMouseDownOnContent(Object.assign({detail: 2}, tripleClickPosition)) - component.didMouseDownOnContent(Object.assign({detail: 3}, tripleClickPosition)) + component.didMouseDownOnContent(Object.assign({detail: 1, button: 0}, tripleClickPosition)) + component.didMouseDownOnContent(Object.assign({detail: 2, button: 0}, tripleClickPosition)) + component.didMouseDownOnContent(Object.assign({detail: 3, button: 0}, tripleClickPosition)) const [didDrag, didStopDragging] = component.handleMouseDragUntilMouseUp.argsForCall[2] didDrag(clientPositionForCharacter(component, 1, 8)) @@ -923,7 +947,7 @@ describe('TextEditorComponent', () => { previousScrollLeft = scroller.scrollLeft } - component.didMouseDownOnContent({detail: 1, clientX: 100, clientY: 100}) + component.didMouseDownOnContent({detail: 1, button: 0, clientX: 100, clientY: 100}) const [didDrag, didStopDragging] = component.handleMouseDragUntilMouseUp.argsForCall[0] didDrag({clientX: 199, clientY: 199}) assertScrolledDownAndRight() diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 0a86629b0df..ee115f0874b 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -760,9 +760,13 @@ class TextEditorComponent { didMouseDownOnContent (event) { const {model} = this.props - const {detail, ctrlKey, shiftKey, metaKey} = event - const screenPosition = this.screenPositionForMouseEvent(event) + const {button, detail, ctrlKey, shiftKey, metaKey} = event + + // Only handle mousedown events for left mouse button (or the middle mouse + // button on Linux where it pastes the selection clipboard). + if (!(button === 0 || (this.getPlatform() === 'linux' && button === 1))) return + const screenPosition = this.screenPositionForMouseEvent(event) const addOrRemoveSelection = metaKey || (ctrlKey && this.getPlatform() !== 'darwin') switch (detail) { From ffc2025df52dd21b25b54c1da88d78ed45cb51cb Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sat, 11 Mar 2017 12:58:18 -0700 Subject: [PATCH 120/403] Handle clicking, shift-clicking, cmd-clicking and dragging in gutter --- spec/text-editor-component-spec.js | 169 ++++++++++++++++++++++++++++- src/text-editor-component.js | 78 +++++++++++-- 2 files changed, 234 insertions(+), 13 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index af10c304a22..8e1452827f8 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -850,7 +850,7 @@ describe('TextEditorComponent', () => { }, clientPositionForCharacter(component, 1, 4))) { - const [didDrag, didStopDragging] = component.handleMouseDragUntilMouseUp.argsForCall[0] + const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[0][0] didDrag(clientPositionForCharacter(component, 8, 8)) expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [8, 8]]) didDrag(clientPositionForCharacter(component, 4, 8)) @@ -867,7 +867,7 @@ describe('TextEditorComponent', () => { metaKey: 1, }, clientPositionForCharacter(component, 8, 8))) { - const [didDrag, didStopDragging] = component.handleMouseDragUntilMouseUp.argsForCall[1] + const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[1][0] didDrag(clientPositionForCharacter(component, 2, 8)) expect(editor.getSelectedScreenRanges()).toEqual([ [[1, 4], [4, 8]], @@ -903,7 +903,7 @@ describe('TextEditorComponent', () => { button: 0, }, clientPositionForCharacter(component, 1, 4))) - const [didDrag, didStopDragging] = component.handleMouseDragUntilMouseUp.argsForCall[1] + const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[1][0] didDrag(clientPositionForCharacter(component, 0, 8)) expect(editor.getSelectedScreenRange()).toEqual([[0, 4], [1, 5]]) didDrag(clientPositionForCharacter(component, 2, 10)) @@ -919,13 +919,172 @@ describe('TextEditorComponent', () => { component.didMouseDownOnContent(Object.assign({detail: 2, button: 0}, tripleClickPosition)) component.didMouseDownOnContent(Object.assign({detail: 3, button: 0}, tripleClickPosition)) - const [didDrag, didStopDragging] = component.handleMouseDragUntilMouseUp.argsForCall[2] + const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[2][0] didDrag(clientPositionForCharacter(component, 1, 8)) expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [3, 0]]) didDrag(clientPositionForCharacter(component, 4, 10)) expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [5, 0]]) }) + describe('on the line number gutter', () => { + it('selects all buffer rows intersecting the clicked screen row when a line number is clicked', async () => { + const {component, editor} = buildComponent() + spyOn(component, 'handleMouseDragUntilMouseUp') + editor.setSoftWrapped(true) + await setEditorWidthInCharacters(component, 50) + editor.foldBufferRange([[4, Infinity], [7, Infinity]]) + await component.getNextUpdatePromise() + + // Selects entire buffer line when clicked screen line is soft-wrapped + component.didMouseDownOnLineNumberGutter({ + button: 0, + clientY: clientTopForLine(component, 3) + }) + expect(editor.getSelectedScreenRange()).toEqual([[3, 0], [5, 0]]) + expect(editor.getSelectedBufferRange()).toEqual([[3, 0], [4, 0]]) + + // Selects entire screen line, even if folds cause that selection to + // span multiple buffer lines + component.didMouseDownOnLineNumberGutter({ + button: 0, + clientY: clientTopForLine(component, 5) + }) + expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [6, 0]]) + expect(editor.getSelectedBufferRange()).toEqual([[4, 0], [8, 0]]) + }) + + it('adds new selections when a line number is meta-clicked', async () => { + const {component, editor} = buildComponent() + editor.setSoftWrapped(true) + await setEditorWidthInCharacters(component, 50) + editor.foldBufferRange([[4, Infinity], [7, Infinity]]) + await component.getNextUpdatePromise() + + // Selects entire buffer line when clicked screen line is soft-wrapped + component.didMouseDownOnLineNumberGutter({ + button: 0, + metaKey: true, + clientY: clientTopForLine(component, 3) + }) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[0, 0], [0, 0]], + [[3, 0], [5, 0]] + ]) + expect(editor.getSelectedBufferRanges()).toEqual([ + [[0, 0], [0, 0]], + [[3, 0], [4, 0]] + ]) + + // Selects entire screen line, even if folds cause that selection to + // span multiple buffer lines + component.didMouseDownOnLineNumberGutter({ + button: 0, + metaKey: true, + clientY: clientTopForLine(component, 5) + }) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[0, 0], [0, 0]], + [[3, 0], [5, 0]], + [[5, 0], [6, 0]] + ]) + expect(editor.getSelectedBufferRanges()).toEqual([ + [[0, 0], [0, 0]], + [[3, 0], [4, 0]], + [[4, 0], [8, 0]] + ]) + }) + + it('expands the last selection when a line number is shift-clicked', async () => { + const {component, editor} = buildComponent() + spyOn(component, 'handleMouseDragUntilMouseUp') + editor.setSoftWrapped(true) + await setEditorWidthInCharacters(component, 50) + editor.foldBufferRange([[4, Infinity], [7, Infinity]]) + await component.getNextUpdatePromise() + + editor.setSelectedScreenRange([[3, 4], [3, 8]]) + editor.addCursorAtScreenPosition([2, 10]) + component.didMouseDownOnLineNumberGutter({ + button: 0, + shiftKey: true, + clientY: clientTopForLine(component, 5) + }) + + expect(editor.getSelectedBufferRanges()).toEqual([ + [[3, 4], [3, 8]], + [[2, 10], [8, 0]] + ]) + + // Original selection is preserved when shift-click-dragging + const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[0][0] + didDrag({ + clientY: clientTopForLine(component, 1) + }) + expect(editor.getSelectedBufferRanges()).toEqual([ + [[3, 4], [3, 8]], + [[1, 0], [2, 10]] + ]) + + didDrag({ + clientY: clientTopForLine(component, 5) + }) + + didStopDragging() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[2, 10], [8, 0]] + ]) + }) + + it('expands the selection when dragging', async () => { + const {component, editor} = buildComponent() + spyOn(component, 'handleMouseDragUntilMouseUp') + editor.setSoftWrapped(true) + await setEditorWidthInCharacters(component, 50) + editor.foldBufferRange([[4, Infinity], [7, Infinity]]) + await component.getNextUpdatePromise() + + editor.setSelectedScreenRange([[3, 4], [3, 6]]) + + component.didMouseDownOnLineNumberGutter({ + button: 0, + metaKey: true, + clientY: clientTopForLine(component, 2) + }) + + const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[0][0] + + didDrag({ + clientY: clientTopForLine(component, 1) + }) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[3, 4], [3, 6]], + [[1, 0], [3, 0]] + ]) + + didDrag({ + clientY: clientTopForLine(component, 5) + }) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[3, 4], [3, 6]], + [[2, 0], [6, 0]] + ]) + expect(editor.isFoldedAtBufferRow(4)).toBe(true) + + didDrag({ + clientY: clientTopForLine(component, 3) + }) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[3, 4], [3, 6]], + [[2, 0], [4, 4]] + ]) + + didStopDragging() + expect(editor.getSelectedScreenRanges()).toEqual([ + [[2, 0], [4, 4]] + ]) + }) + }) + it('autoscrolls the content when dragging near the edge of the screen', async () => { const {component, editor} = buildComponent({width: 200, height: 200}) const {scroller} = component.refs @@ -948,7 +1107,7 @@ describe('TextEditorComponent', () => { } component.didMouseDownOnContent({detail: 1, button: 0, clientX: 100, clientY: 100}) - const [didDrag, didStopDragging] = component.handleMouseDragUntilMouseUp.argsForCall[0] + const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[0][0] didDrag({clientX: 199, clientY: 199}) assertScrolledDownAndRight() didDrag({clientX: 199, clientY: 199}) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index ee115f0874b..72342530cc2 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1,6 +1,6 @@ const etch = require('etch') const {CompositeDisposable} = require('event-kit') -const {Point} = require('text-buffer') +const {Point, Range} = require('text-buffer') const resizeDetector = require('element-resize-detector')({strategy: 'scroll'}) const TextEditor = require('./text-editor') const {isPairedCharacter} = require('./text-utils') @@ -14,6 +14,7 @@ const DOUBLE_WIDTH_CHARACTER = '我' const HALF_WIDTH_CHARACTER = 'ハ' const KOREAN_CHARACTER = '세' const NBSP_CHARACTER = '\u00a0' +const ZERO_WIDTH_NBSP_CHARACTER = '\ufeff' const MOUSE_DRAG_AUTOSCROLL_MARGIN = 40 function scaleMouseDragAutoscrollDelta (delta) { @@ -796,29 +797,83 @@ class TextEditorComponent { break } - this.handleMouseDragUntilMouseUp( - (event) => { + this.handleMouseDragUntilMouseUp({ + didDrag: (event) => { this.autoscrollOnMouseDrag(event) const screenPosition = this.screenPositionForMouseEvent(event) model.selectToScreenPosition(screenPosition, {suppressSelectionMerge: true, autoscroll: false}) this.updateSync() }, - () => { + didStopDragging: () => { model.finalizeSelections() model.mergeIntersectingSelections() this.updateSync() } - ) + }) } - handleMouseDragUntilMouseUp (didDragCallback, didStopDragging) { + didMouseDownOnLineNumberGutter (event) { + if (global.debug) debugger + + const {model} = this.props + const {button, ctrlKey, shiftKey, metaKey} = event + + // Only handle mousedown events for left mouse button + if (button !== 0) return + + const addOrRemoveSelection = metaKey || (ctrlKey && this.getPlatform() !== 'darwin') + const clickedScreenRow = this.screenPositionForMouseEvent(event).row + const startBufferRow = model.bufferPositionForScreenPosition([clickedScreenRow, 0]).row + const endBufferRow = model.bufferPositionForScreenPosition([clickedScreenRow, Infinity]).row + const clickedLineBufferRange = Range(Point(startBufferRow, 0), Point(endBufferRow + 1, 0)) + + let initialBufferRange + if (shiftKey) { + const lastSelection = model.getLastSelection() + initialBufferRange = lastSelection.getBufferRange() + lastSelection.setBufferRange(initialBufferRange.union(clickedLineBufferRange), { + reversed: clickedScreenRow < lastSelection.getScreenRange().start.row, + autoscroll: false, + preserveFolds: true, + suppressSelectionMerge: true + }) + } else { + initialBufferRange = clickedLineBufferRange + if (addOrRemoveSelection) { + model.addSelectionForBufferRange(clickedLineBufferRange, {autoscroll: false, preserveFolds: true}) + } else { + model.setSelectedBufferRange(clickedLineBufferRange, {autoscroll: false, preserveFolds: true}) + } + } + + const initialScreenRange = model.screenRangeForBufferRange(initialBufferRange) + this.handleMouseDragUntilMouseUp({ + didDrag: (event) => { + const dragRow = this.screenPositionForMouseEvent(event).row + const draggedLineScreenRange = Range(Point(dragRow, 0), Point(dragRow + 1, 0)) + model.getLastSelection().setScreenRange(draggedLineScreenRange.union(initialScreenRange), { + reversed: dragRow < initialScreenRange.start.row, + autoscroll: false, + preserveFolds: true + }) + this.updateSync() + }, + didStopDragging: () => { + model.mergeIntersectingSelections() + this.updateSync() + } + }) + + } + + handleMouseDragUntilMouseUp ({didDrag, didStopDragging}) { let dragging = false let lastMousemoveEvent const animationFrameLoop = () => { window.requestAnimationFrame(() => { if (dragging && this.visible) { - didDragCallback(lastMousemoveEvent) + didDrag(lastMousemoveEvent) animationFrameLoop() } }) @@ -1503,6 +1558,9 @@ class LineNumberGutterComponent { children[tileIndex] = $.div({ key: tileIndex, + on: { + mousedown: this.didMouseDown + }, style: { contain: 'strict', overflow: 'hidden', @@ -1547,6 +1605,10 @@ class LineNumberGutterComponent { if (!arraysEqual(oldProps.lineNumberDecorations, newProps.lineNumberDecorations)) return true return false } + + didMouseDown (event) { + this.props.parentComponent.didMouseDownOnLineNumberGutter(event) + } } class LinesTileComponent { @@ -1718,7 +1780,7 @@ class LineComponent { // Insert a zero-width non-breaking whitespace, so that LinesYardstick can // take the fold-marker::after pseudo-element into account during // measurements when such marker is the last character on the line. - const textNode = document.createTextNode(ZERO_WIDTH_NBSP) + const textNode = document.createTextNode(ZERO_WIDTH_NBSP_CHARACTER) this.element.appendChild(textNode) textNodes.push(textNode) } From 6e9a9ef43c68b41f3ecced820630395ab416a47f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 14 Mar 2017 14:57:28 -0600 Subject: [PATCH 121/403] Add spec structure --- spec/text-editor-component-spec.js | 686 +++++++++++++++-------------- src/text-editor-component.js | 1 - 2 files changed, 344 insertions(+), 343 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 8e1452827f8..df5a2287f65 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -272,7 +272,7 @@ describe('TextEditorComponent', () => { }) }) - describe('autoscroll', () => { + describe('autoscroll on cursor movement', () => { it('automatically scrolls vertically when the requested range is within the vertical scroll margin of the top or bottom', async () => { const {component, element, editor} = buildComponent({height: 120}) const {scroller} = component.refs @@ -594,336 +594,397 @@ describe('TextEditorComponent', () => { }) describe('mouse input', () => { - it('positions the cursor on single-click', async () => { - const {component, element, editor} = buildComponent() - const {lineHeight} = component.measurements + describe('on the lines', () => { + it('positions the cursor on single-click', async () => { + const {component, element, editor} = buildComponent() + const {lineHeight} = component.measurements - component.didMouseDownOnContent({ - detail: 1, - button: 0, - clientX: clientLeftForCharacter(component, 0, editor.lineLengthForScreenRow(0)) + 1, - clientY: clientTopForLine(component, 0) + lineHeight / 2 - }) - expect(editor.getCursorScreenPosition()).toEqual([0, editor.lineLengthForScreenRow(0)]) + component.didMouseDownOnContent({ + detail: 1, + button: 0, + clientX: clientLeftForCharacter(component, 0, editor.lineLengthForScreenRow(0)) + 1, + clientY: clientTopForLine(component, 0) + lineHeight / 2 + }) + expect(editor.getCursorScreenPosition()).toEqual([0, editor.lineLengthForScreenRow(0)]) + + component.didMouseDownOnContent({ + detail: 1, + button: 0, + clientX: (clientLeftForCharacter(component, 3, 0) + clientLeftForCharacter(component, 3, 1)) / 2, + clientY: clientTopForLine(component, 1) + lineHeight / 2 + }) + expect(editor.getCursorScreenPosition()).toEqual([1, 0]) + + component.didMouseDownOnContent({ + detail: 1, + button: 0, + clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 15)) / 2, + clientY: clientTopForLine(component, 3) + lineHeight / 2 + }) + expect(editor.getCursorScreenPosition()).toEqual([3, 14]) + + component.didMouseDownOnContent({ + detail: 1, + button: 0, + clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 15)) / 2 + 1, + clientY: clientTopForLine(component, 3) + lineHeight / 2 + }) + expect(editor.getCursorScreenPosition()).toEqual([3, 15]) + + editor.getBuffer().setTextInRange([[3, 14], [3, 15]], '🐣') + await component.getNextUpdatePromise() + + component.didMouseDownOnContent({ + detail: 1, + button: 0, + clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 16)) / 2, + clientY: clientTopForLine(component, 3) + lineHeight / 2 + }) + expect(editor.getCursorScreenPosition()).toEqual([3, 14]) - component.didMouseDownOnContent({ - detail: 1, - button: 0, - clientX: (clientLeftForCharacter(component, 3, 0) + clientLeftForCharacter(component, 3, 1)) / 2, - clientY: clientTopForLine(component, 1) + lineHeight / 2 + component.didMouseDownOnContent({ + detail: 1, + button: 0, + clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 16)) / 2 + 1, + clientY: clientTopForLine(component, 3) + lineHeight / 2 + }) + expect(editor.getCursorScreenPosition()).toEqual([3, 16]) }) - expect(editor.getCursorScreenPosition()).toEqual([1, 0]) - component.didMouseDownOnContent({ - detail: 1, - button: 0, - clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 15)) / 2, - clientY: clientTopForLine(component, 3) + lineHeight / 2 + it('selects words on double-click', () => { + const {component, editor} = buildComponent() + const {clientX, clientY} = clientPositionForCharacter(component, 1, 16) + component.didMouseDownOnContent({detail: 1, button: 0, clientX, clientY}) + component.didMouseDownOnContent({detail: 2, button: 0, clientX, clientY}) + expect(editor.getSelectedScreenRange()).toEqual([[1, 13], [1, 21]]) }) - expect(editor.getCursorScreenPosition()).toEqual([3, 14]) - component.didMouseDownOnContent({ - detail: 1, - button: 0, - clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 15)) / 2 + 1, - clientY: clientTopForLine(component, 3) + lineHeight / 2 + it('selects lines on triple-click', () => { + const {component, editor} = buildComponent() + const {clientX, clientY} = clientPositionForCharacter(component, 1, 16) + component.didMouseDownOnContent({detail: 1, button: 0, clientX, clientY}) + component.didMouseDownOnContent({detail: 2, button: 0, clientX, clientY}) + component.didMouseDownOnContent({detail: 3, button: 0, clientX, clientY}) + expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [2, 0]]) }) - expect(editor.getCursorScreenPosition()).toEqual([3, 15]) - editor.getBuffer().setTextInRange([[3, 14], [3, 15]], '🐣') - await component.getNextUpdatePromise() + it('adds or removes cursors when holding cmd or ctrl when single-clicking', () => { + const {component, editor} = buildComponent() + spyOn(component, 'getPlatform').andCallFake(() => mockedPlatform) + + let mockedPlatform = 'darwin' + expect(editor.getCursorScreenPositions()).toEqual([[0, 0]]) + + // add cursor at 1, 16 + component.didMouseDownOnContent( + Object.assign(clientPositionForCharacter(component, 1, 16), { + detail: 1, + button: 0, + metaKey: true + }) + ) + expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]]) + + // remove cursor at 0, 0 + component.didMouseDownOnContent( + Object.assign(clientPositionForCharacter(component, 0, 0), { + detail: 1, + button: 0, + metaKey: true + }) + ) + expect(editor.getCursorScreenPositions()).toEqual([[1, 16]]) + + // cmd-click cursor at 1, 16 but don't remove it because it's the last one + component.didMouseDownOnContent( + Object.assign(clientPositionForCharacter(component, 1, 16), { + detail: 1, + button: 0, + metaKey: true + }) + ) + expect(editor.getCursorScreenPositions()).toEqual([[1, 16]]) + + // cmd-clicking within a selection destroys it + editor.addSelectionForScreenRange([[2, 10], [2, 15]]) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[1, 16], [1, 16]], + [[2, 10], [2, 15]] + ]) + component.didMouseDownOnContent( + Object.assign(clientPositionForCharacter(component, 2, 13), { + detail: 1, + button: 0, + metaKey: true + }) + ) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[1, 16], [1, 16]] + ]) - component.didMouseDownOnContent({ - detail: 1, - button: 0, - clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 16)) / 2, - clientY: clientTopForLine(component, 3) + lineHeight / 2 + // ctrl-click does not add cursors on macOS + component.didMouseDownOnContent( + Object.assign(clientPositionForCharacter(component, 1, 4), { + detail: 1, + button: 0, + ctrlKey: true + }) + ) + expect(editor.getCursorScreenPositions()).toEqual([[1, 4]]) + + mockedPlatform = 'win32' + + // ctrl-click adds cursors on platforms *other* than macOS + component.didMouseDownOnContent( + Object.assign(clientPositionForCharacter(component, 1, 16), { + detail: 1, + button: 0, + ctrlKey: true + }) + ) + expect(editor.getCursorScreenPositions()).toEqual([[1, 4], [1, 16]]) }) - expect(editor.getCursorScreenPosition()).toEqual([3, 14]) - component.didMouseDownOnContent({ - detail: 1, - button: 0, - clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 16)) / 2 + 1, - clientY: clientTopForLine(component, 3) + lineHeight / 2 + it('adds word selections when holding cmd or ctrl when double-clicking', () => { + const {component, editor} = buildComponent() + editor.addCursorAtScreenPosition([1, 16]) + expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]]) + + component.didMouseDownOnContent( + Object.assign(clientPositionForCharacter(component, 1, 16), { + detail: 1, + button: 0, + metaKey: true + }) + ) + component.didMouseDownOnContent( + Object.assign(clientPositionForCharacter(component, 1, 16), { + detail: 2, + button: 0, + metaKey: true + }) + ) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[0, 0], [0, 0]], + [[1, 13], [1, 21]] + ]) }) - expect(editor.getCursorScreenPosition()).toEqual([3, 16]) - }) - it('selects words on double-click', () => { - const {component, editor} = buildComponent() - const {clientX, clientY} = clientPositionForCharacter(component, 1, 16) - component.didMouseDownOnContent({detail: 1, button: 0, clientX, clientY}) - component.didMouseDownOnContent({detail: 2, button: 0, clientX, clientY}) - expect(editor.getSelectedScreenRange()).toEqual([[1, 13], [1, 21]]) - }) + it('adds line selections when holding cmd or ctrl when triple-clicking', () => { + const {component, editor} = buildComponent() + editor.addCursorAtScreenPosition([1, 16]) + expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]]) - it('selects lines on triple-click', () => { - const {component, editor} = buildComponent() - const {clientX, clientY} = clientPositionForCharacter(component, 1, 16) - component.didMouseDownOnContent({detail: 1, button: 0, clientX, clientY}) - component.didMouseDownOnContent({detail: 2, button: 0, clientX, clientY}) - component.didMouseDownOnContent({detail: 3, button: 0, clientX, clientY}) - expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [2, 0]]) - }) + const {clientX, clientY} = clientPositionForCharacter(component, 1, 16) + component.didMouseDownOnContent({detail: 1, button: 0, metaKey: true, clientX, clientY}) + component.didMouseDownOnContent({detail: 2, button: 0, metaKey: true, clientX, clientY}) + component.didMouseDownOnContent({detail: 3, button: 0, metaKey: true, clientX, clientY}) - it('adds or removes cursors when holding cmd or ctrl when single-clicking', () => { - const {component, editor} = buildComponent() - spyOn(component, 'getPlatform').andCallFake(() => mockedPlatform) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[0, 0], [0, 0]], + [[1, 0], [2, 0]] + ]) + }) - let mockedPlatform = 'darwin' - expect(editor.getCursorScreenPositions()).toEqual([[0, 0]]) + it('expands the last selection on shift-click', () => { + const {component, element, editor} = buildComponent() - // add cursor at 1, 16 - component.didMouseDownOnContent( - Object.assign(clientPositionForCharacter(component, 1, 16), { + editor.setCursorScreenPosition([2, 18]) + component.didMouseDownOnContent(Object.assign({ detail: 1, button: 0, - metaKey: true - }) - ) - expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]]) + shiftKey: true + }, clientPositionForCharacter(component, 1, 4))) + expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [2, 18]]) - // remove cursor at 0, 0 - component.didMouseDownOnContent( - Object.assign(clientPositionForCharacter(component, 0, 0), { + component.didMouseDownOnContent(Object.assign({ detail: 1, button: 0, - metaKey: true - }) - ) - expect(editor.getCursorScreenPositions()).toEqual([[1, 16]]) + shiftKey: true + }, clientPositionForCharacter(component, 4, 4))) + expect(editor.getSelectedScreenRange()).toEqual([[2, 18], [4, 4]]) + + // reorients word-wise selections to keep the word selected regardless of + // where the subsequent shift-click occurs + editor.setCursorScreenPosition([2, 18]) + editor.getLastSelection().selectWord() + component.didMouseDownOnContent(Object.assign({ + detail: 1, + button: 0, + shiftKey: true + }, clientPositionForCharacter(component, 1, 4))) + expect(editor.getSelectedScreenRange()).toEqual([[1, 2], [2, 20]]) - // cmd-click cursor at 1, 16 but don't remove it because it's the last one - component.didMouseDownOnContent( - Object.assign(clientPositionForCharacter(component, 1, 16), { + component.didMouseDownOnContent(Object.assign({ detail: 1, button: 0, - metaKey: true - }) - ) - expect(editor.getCursorScreenPositions()).toEqual([[1, 16]]) - - // cmd-clicking within a selection destroys it - editor.addSelectionForScreenRange([[2, 10], [2, 15]]) - expect(editor.getSelectedScreenRanges()).toEqual([ - [[1, 16], [1, 16]], - [[2, 10], [2, 15]] - ]) - component.didMouseDownOnContent( - Object.assign(clientPositionForCharacter(component, 2, 13), { + shiftKey: true + }, clientPositionForCharacter(component, 3, 11))) + expect(editor.getSelectedScreenRange()).toEqual([[2, 14], [3, 13]]) + + // reorients line-wise selections to keep the word selected regardless of + // where the subsequent shift-click occurs + editor.setCursorScreenPosition([2, 18]) + editor.getLastSelection().selectLine() + component.didMouseDownOnContent(Object.assign({ detail: 1, button: 0, - metaKey: true - }) - ) - expect(editor.getSelectedScreenRanges()).toEqual([ - [[1, 16], [1, 16]] - ]) + shiftKey: true + }, clientPositionForCharacter(component, 1, 4))) + expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [3, 0]]) - // ctrl-click does not add cursors on macOS - component.didMouseDownOnContent( - Object.assign(clientPositionForCharacter(component, 1, 4), { + component.didMouseDownOnContent(Object.assign({ detail: 1, button: 0, - ctrlKey: true - }) - ) - expect(editor.getCursorScreenPositions()).toEqual([[1, 4]]) + shiftKey: true + }, clientPositionForCharacter(component, 3, 11))) + expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [4, 0]]) + }) - mockedPlatform = 'win32' + it('expands the last selection on drag', () => { + const {component, editor} = buildComponent() + spyOn(component, 'handleMouseDragUntilMouseUp') - // ctrl-click adds cursors on platforms *other* than macOS - component.didMouseDownOnContent( - Object.assign(clientPositionForCharacter(component, 1, 16), { + component.didMouseDownOnContent(Object.assign({ detail: 1, button: 0, - ctrlKey: true - }) - ) - expect(editor.getCursorScreenPositions()).toEqual([[1, 4], [1, 16]]) - }) + }, clientPositionForCharacter(component, 1, 4))) + + { + const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[0][0] + didDrag(clientPositionForCharacter(component, 8, 8)) + expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [8, 8]]) + didDrag(clientPositionForCharacter(component, 4, 8)) + expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [4, 8]]) + didStopDragging() + expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [4, 8]]) + } + + // Click-drag a second selection... selections are not merged until the + // drag stops. + component.didMouseDownOnContent(Object.assign({ + detail: 1, + button: 0, + metaKey: 1, + }, clientPositionForCharacter(component, 8, 8))) + { + const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[1][0] + didDrag(clientPositionForCharacter(component, 2, 8)) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[1, 4], [4, 8]], + [[2, 8], [8, 8]] + ]) + didDrag(clientPositionForCharacter(component, 6, 8)) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[1, 4], [4, 8]], + [[6, 8], [8, 8]] + ]) + didDrag(clientPositionForCharacter(component, 2, 8)) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[1, 4], [4, 8]], + [[2, 8], [8, 8]] + ]) + didStopDragging() + expect(editor.getSelectedScreenRanges()).toEqual([ + [[1, 4], [8, 8]] + ]) + } + }) - it('adds word selections when holding cmd or ctrl when double-clicking', () => { - const {component, editor} = buildComponent() - editor.addCursorAtScreenPosition([1, 16]) - expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]]) + it('expands the selection word-wise on double-click-drag', () => { + const {component, editor} = buildComponent() + spyOn(component, 'handleMouseDragUntilMouseUp') - component.didMouseDownOnContent( - Object.assign(clientPositionForCharacter(component, 1, 16), { + component.didMouseDownOnContent(Object.assign({ detail: 1, button: 0, - metaKey: true - }) - ) - component.didMouseDownOnContent( - Object.assign(clientPositionForCharacter(component, 1, 16), { + }, clientPositionForCharacter(component, 1, 4))) + component.didMouseDownOnContent(Object.assign({ detail: 2, button: 0, - metaKey: true - }) - ) - expect(editor.getSelectedScreenRanges()).toEqual([ - [[0, 0], [0, 0]], - [[1, 13], [1, 21]] - ]) - }) - - it('adds line selections when holding cmd or ctrl when triple-clicking', () => { - const {component, editor} = buildComponent() - editor.addCursorAtScreenPosition([1, 16]) - expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]]) + }, clientPositionForCharacter(component, 1, 4))) - const {clientX, clientY} = clientPositionForCharacter(component, 1, 16) - component.didMouseDownOnContent({detail: 1, button: 0, metaKey: true, clientX, clientY}) - component.didMouseDownOnContent({detail: 2, button: 0, metaKey: true, clientX, clientY}) - component.didMouseDownOnContent({detail: 3, button: 0, metaKey: true, clientX, clientY}) - - expect(editor.getSelectedScreenRanges()).toEqual([ - [[0, 0], [0, 0]], - [[1, 0], [2, 0]] - ]) - }) + const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[1][0] + didDrag(clientPositionForCharacter(component, 0, 8)) + expect(editor.getSelectedScreenRange()).toEqual([[0, 4], [1, 5]]) + didDrag(clientPositionForCharacter(component, 2, 10)) + expect(editor.getSelectedScreenRange()).toEqual([[1, 2], [2, 13]]) + }) - it('expands the last selection on shift-click', () => { - const {component, element, editor} = buildComponent() + it('expands the selection line-wise on triple-click-drag', () => { + const {component, editor} = buildComponent() + spyOn(component, 'handleMouseDragUntilMouseUp') - editor.setCursorScreenPosition([2, 18]) - component.didMouseDownOnContent(Object.assign({ - detail: 1, - button: 0, - shiftKey: true - }, clientPositionForCharacter(component, 1, 4))) - expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [2, 18]]) - - component.didMouseDownOnContent(Object.assign({ - detail: 1, - button: 0, - shiftKey: true - }, clientPositionForCharacter(component, 4, 4))) - expect(editor.getSelectedScreenRange()).toEqual([[2, 18], [4, 4]]) - - // reorients word-wise selections to keep the word selected regardless of - // where the subsequent shift-click occurs - editor.setCursorScreenPosition([2, 18]) - editor.getLastSelection().selectWord() - component.didMouseDownOnContent(Object.assign({ - detail: 1, - button: 0, - shiftKey: true - }, clientPositionForCharacter(component, 1, 4))) - expect(editor.getSelectedScreenRange()).toEqual([[1, 2], [2, 20]]) - - component.didMouseDownOnContent(Object.assign({ - detail: 1, - button: 0, - shiftKey: true - }, clientPositionForCharacter(component, 3, 11))) - expect(editor.getSelectedScreenRange()).toEqual([[2, 14], [3, 13]]) - - // reorients line-wise selections to keep the word selected regardless of - // where the subsequent shift-click occurs - editor.setCursorScreenPosition([2, 18]) - editor.getLastSelection().selectLine() - component.didMouseDownOnContent(Object.assign({ - detail: 1, - button: 0, - shiftKey: true - }, clientPositionForCharacter(component, 1, 4))) - expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [3, 0]]) - - component.didMouseDownOnContent(Object.assign({ - detail: 1, - button: 0, - shiftKey: true - }, clientPositionForCharacter(component, 3, 11))) - expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [4, 0]]) - }) + const tripleClickPosition = clientPositionForCharacter(component, 2, 8) + component.didMouseDownOnContent(Object.assign({detail: 1, button: 0}, tripleClickPosition)) + component.didMouseDownOnContent(Object.assign({detail: 2, button: 0}, tripleClickPosition)) + component.didMouseDownOnContent(Object.assign({detail: 3, button: 0}, tripleClickPosition)) - it('expands the last selection on drag', () => { - const {component, editor} = buildComponent() - spyOn(component, 'handleMouseDragUntilMouseUp') + const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[2][0] + didDrag(clientPositionForCharacter(component, 1, 8)) + expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [3, 0]]) + didDrag(clientPositionForCharacter(component, 4, 10)) + expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [5, 0]]) + }) - component.didMouseDownOnContent(Object.assign({ - detail: 1, - button: 0, - }, clientPositionForCharacter(component, 1, 4))) + it('autoscrolls the content when dragging near the edge of the screen', async () => { + const {component, editor} = buildComponent({width: 200, height: 200}) + const {scroller} = component.refs + spyOn(component, 'handleMouseDragUntilMouseUp') - { + let previousScrollTop = 0 + let previousScrollLeft = 0 + function assertScrolledDownAndRight () { + expect(scroller.scrollTop).toBeGreaterThan(previousScrollTop) + previousScrollTop = scroller.scrollTop + expect(scroller.scrollLeft).toBeGreaterThan(previousScrollLeft) + previousScrollLeft = scroller.scrollLeft + } + + function assertScrolledUpAndLeft () { + expect(scroller.scrollTop).toBeLessThan(previousScrollTop) + previousScrollTop = scroller.scrollTop + expect(scroller.scrollLeft).toBeLessThan(previousScrollLeft) + previousScrollLeft = scroller.scrollLeft + } + + component.didMouseDownOnContent({detail: 1, button: 0, clientX: 100, clientY: 100}) const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[0][0] - didDrag(clientPositionForCharacter(component, 8, 8)) - expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [8, 8]]) - didDrag(clientPositionForCharacter(component, 4, 8)) - expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [4, 8]]) - didStopDragging() - expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [4, 8]]) - } - - // Click-drag a second selection... selections are not merged until the - // drag stops. - component.didMouseDownOnContent(Object.assign({ - detail: 1, - button: 0, - metaKey: 1, - }, clientPositionForCharacter(component, 8, 8))) - { - const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[1][0] - didDrag(clientPositionForCharacter(component, 2, 8)) - expect(editor.getSelectedScreenRanges()).toEqual([ - [[1, 4], [4, 8]], - [[2, 8], [8, 8]] - ]) - didDrag(clientPositionForCharacter(component, 6, 8)) - expect(editor.getSelectedScreenRanges()).toEqual([ - [[1, 4], [4, 8]], - [[6, 8], [8, 8]] - ]) - didDrag(clientPositionForCharacter(component, 2, 8)) - expect(editor.getSelectedScreenRanges()).toEqual([ - [[1, 4], [4, 8]], - [[2, 8], [8, 8]] - ]) - didStopDragging() - expect(editor.getSelectedScreenRanges()).toEqual([ - [[1, 4], [8, 8]] - ]) - } - }) - - it('expands the selection word-wise on double-click-drag', () => { - const {component, editor} = buildComponent() - spyOn(component, 'handleMouseDragUntilMouseUp') - - component.didMouseDownOnContent(Object.assign({ - detail: 1, - button: 0, - }, clientPositionForCharacter(component, 1, 4))) - component.didMouseDownOnContent(Object.assign({ - detail: 2, - button: 0, - }, clientPositionForCharacter(component, 1, 4))) - - const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[1][0] - didDrag(clientPositionForCharacter(component, 0, 8)) - expect(editor.getSelectedScreenRange()).toEqual([[0, 4], [1, 5]]) - didDrag(clientPositionForCharacter(component, 2, 10)) - expect(editor.getSelectedScreenRange()).toEqual([[1, 2], [2, 13]]) - }) - - it('expands the selection line-wise on triple-click-drag', () => { - const {component, editor} = buildComponent() - spyOn(component, 'handleMouseDragUntilMouseUp') - - const tripleClickPosition = clientPositionForCharacter(component, 2, 8) - component.didMouseDownOnContent(Object.assign({detail: 1, button: 0}, tripleClickPosition)) - component.didMouseDownOnContent(Object.assign({detail: 2, button: 0}, tripleClickPosition)) - component.didMouseDownOnContent(Object.assign({detail: 3, button: 0}, tripleClickPosition)) + didDrag({clientX: 199, clientY: 199}) + assertScrolledDownAndRight() + didDrag({clientX: 199, clientY: 199}) + assertScrolledDownAndRight() + didDrag({clientX: 199, clientY: 199}) + assertScrolledDownAndRight() + didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) + assertScrolledUpAndLeft() + didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) + assertScrolledUpAndLeft() + didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) + assertScrolledUpAndLeft() + + // Don't artificially update scroll measurements beyond the minimum or + // maximum possible scroll positions + expect(scroller.scrollTop).toBe(0) + expect(scroller.scrollLeft).toBe(0) + didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) + expect(component.measurements.scrollTop).toBe(0) + expect(scroller.scrollTop).toBe(0) + expect(component.measurements.scrollLeft).toBe(0) + expect(scroller.scrollLeft).toBe(0) + + const maxScrollTop = scroller.scrollHeight - scroller.clientHeight + const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth + scroller.scrollTop = maxScrollTop + scroller.scrollLeft = maxScrollLeft + await component.getNextUpdatePromise() - const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[2][0] - didDrag(clientPositionForCharacter(component, 1, 8)) - expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [3, 0]]) - didDrag(clientPositionForCharacter(component, 4, 10)) - expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [5, 0]]) + didDrag({clientX: 199, clientY: 199}) + didDrag({clientX: 199, clientY: 199}) + didDrag({clientX: 199, clientY: 199}) + expect(component.measurements.scrollTop).toBe(maxScrollTop) + expect(component.measurements.scrollLeft).toBe(maxScrollLeft) + }) }) describe('on the line number gutter', () => { @@ -1084,65 +1145,6 @@ describe('TextEditorComponent', () => { ]) }) }) - - it('autoscrolls the content when dragging near the edge of the screen', async () => { - const {component, editor} = buildComponent({width: 200, height: 200}) - const {scroller} = component.refs - spyOn(component, 'handleMouseDragUntilMouseUp') - - let previousScrollTop = 0 - let previousScrollLeft = 0 - function assertScrolledDownAndRight () { - expect(scroller.scrollTop).toBeGreaterThan(previousScrollTop) - previousScrollTop = scroller.scrollTop - expect(scroller.scrollLeft).toBeGreaterThan(previousScrollLeft) - previousScrollLeft = scroller.scrollLeft - } - - function assertScrolledUpAndLeft () { - expect(scroller.scrollTop).toBeLessThan(previousScrollTop) - previousScrollTop = scroller.scrollTop - expect(scroller.scrollLeft).toBeLessThan(previousScrollLeft) - previousScrollLeft = scroller.scrollLeft - } - - component.didMouseDownOnContent({detail: 1, button: 0, clientX: 100, clientY: 100}) - const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[0][0] - didDrag({clientX: 199, clientY: 199}) - assertScrolledDownAndRight() - didDrag({clientX: 199, clientY: 199}) - assertScrolledDownAndRight() - didDrag({clientX: 199, clientY: 199}) - assertScrolledDownAndRight() - didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) - assertScrolledUpAndLeft() - didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) - assertScrolledUpAndLeft() - didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) - assertScrolledUpAndLeft() - - // Don't artificially update scroll measurements beyond the minimum or - // maximum possible scroll positions - expect(scroller.scrollTop).toBe(0) - expect(scroller.scrollLeft).toBe(0) - didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) - expect(component.measurements.scrollTop).toBe(0) - expect(scroller.scrollTop).toBe(0) - expect(component.measurements.scrollLeft).toBe(0) - expect(scroller.scrollLeft).toBe(0) - - const maxScrollTop = scroller.scrollHeight - scroller.clientHeight - const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth - scroller.scrollTop = maxScrollTop - scroller.scrollLeft = maxScrollLeft - await component.getNextUpdatePromise() - - didDrag({clientX: 199, clientY: 199}) - didDrag({clientX: 199, clientY: 199}) - didDrag({clientX: 199, clientY: 199}) - expect(component.measurements.scrollTop).toBe(maxScrollTop) - expect(component.measurements.scrollLeft).toBe(maxScrollLeft) - }) }) }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 72342530cc2..0a8cf41257a 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -863,7 +863,6 @@ class TextEditorComponent { this.updateSync() } }) - } handleMouseDragUntilMouseUp ({didDrag, didStopDragging}) { From cf19d0efd537757a1bd8706e349f119dff7061b3 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 14 Mar 2017 15:10:01 -0600 Subject: [PATCH 122/403] Autoscroll vertically when click-dragging the line number gutter --- spec/text-editor-component-spec.js | 59 ++++++++++++++++++++++++++++++ src/text-editor-component.js | 5 ++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index df5a2287f65..c532c151896 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1144,6 +1144,65 @@ describe('TextEditorComponent', () => { [[2, 0], [4, 4]] ]) }) + + it('autoscrolls the content when dragging near the edge of the screen', async () => { + const {component, editor} = buildComponent({width: 200, height: 200}) + const {scroller} = component.refs + spyOn(component, 'handleMouseDragUntilMouseUp') + + let previousScrollTop = 0 + let previousScrollLeft = 0 + function assertScrolledDown () { + expect(scroller.scrollTop).toBeGreaterThan(previousScrollTop) + previousScrollTop = scroller.scrollTop + expect(scroller.scrollLeft).toBe(previousScrollLeft) + previousScrollLeft = scroller.scrollLeft + } + + function assertScrolledUp () { + expect(scroller.scrollTop).toBeLessThan(previousScrollTop) + previousScrollTop = scroller.scrollTop + expect(scroller.scrollLeft).toBe(previousScrollLeft) + previousScrollLeft = scroller.scrollLeft + } + + component.didMouseDownOnLineNumberGutter({detail: 1, button: 0, clientX: 0, clientY: 100}) + const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[0][0] + didDrag({clientX: 199, clientY: 199}) + assertScrolledDown() + didDrag({clientX: 199, clientY: 199}) + assertScrolledDown() + didDrag({clientX: 199, clientY: 199}) + assertScrolledDown() + didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) + assertScrolledUp() + didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) + assertScrolledUp() + didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) + assertScrolledUp() + + // Don't artificially update scroll measurements beyond the minimum or + // maximum possible scroll positions + expect(scroller.scrollTop).toBe(0) + expect(scroller.scrollLeft).toBe(0) + didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) + expect(component.measurements.scrollTop).toBe(0) + expect(scroller.scrollTop).toBe(0) + expect(component.measurements.scrollLeft).toBe(0) + expect(scroller.scrollLeft).toBe(0) + + const maxScrollTop = scroller.scrollHeight - scroller.clientHeight + const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth + scroller.scrollTop = maxScrollTop + scroller.scrollLeft = maxScrollLeft + await component.getNextUpdatePromise() + + didDrag({clientX: 199, clientY: 199}) + didDrag({clientX: 199, clientY: 199}) + didDrag({clientX: 199, clientY: 199}) + expect(component.measurements.scrollTop).toBe(maxScrollTop) + expect(component.measurements.scrollLeft).toBe(maxScrollLeft) + }) }) }) }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 0a8cf41257a..aef4b9b5431 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -849,6 +849,7 @@ class TextEditorComponent { const initialScreenRange = model.screenRangeForBufferRange(initialBufferRange) this.handleMouseDragUntilMouseUp({ didDrag: (event) => { + this.autoscrollOnMouseDrag(event, true) const dragRow = this.screenPositionForMouseEvent(event).row const draggedLineScreenRange = Range(Point(dragRow, 0), Point(dragRow + 1, 0)) model.getLastSelection().setScreenRange(draggedLineScreenRange.union(initialScreenRange), { @@ -899,7 +900,7 @@ class TextEditorComponent { window.addEventListener('mouseup', didMouseUp) } - autoscrollOnMouseDrag ({clientX, clientY}) { + autoscrollOnMouseDrag ({clientX, clientY}, verticalOnly = false) { let {top, bottom, left, right} = this.refs.scroller.getBoundingClientRect() top += MOUSE_DRAG_AUTOSCROLL_MARGIN bottom -= MOUSE_DRAG_AUTOSCROLL_MARGIN @@ -935,7 +936,7 @@ class TextEditorComponent { } } - if (xDelta != null) { + if (!verticalOnly && xDelta != null) { const scaledDelta = scaleMouseDragAutoscrollDelta(xDelta) * xDirection const newScrollLeft = this.constrainScrollLeft(this.measurements.scrollLeft + scaledDelta) if (newScrollLeft !== this.measurements.scrollLeft) { From a2f75c8337821a2983627cddd40a99bcb54f3236 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 14 Mar 2017 15:35:13 -0600 Subject: [PATCH 123/403] Toggle folds when clicking the arrow icon in the line number gutter --- spec/text-editor-component-spec.js | 13 +++++++++++++ src/text-editor-component.js | 12 ++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index c532c151896..cfd0e1afd8d 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1145,6 +1145,19 @@ describe('TextEditorComponent', () => { ]) }) + it('toggles folding when clicking on the right icon of a foldable line number', async () => { + const {component, element, editor} = buildComponent() + const target = element.querySelectorAll('.line-number')[1].querySelector('.icon-right') + expect(editor.isFoldedAtScreenRow(1)).toBe(false) + + component.didMouseDownOnLineNumberGutter({target, button: 0, clientY: clientTopForLine(component, 1)}) + expect(editor.isFoldedAtScreenRow(1)).toBe(true) + await component.getNextUpdatePromise() + + component.didMouseDownOnLineNumberGutter({target, button: 0, clientY: clientTopForLine(component, 1)}) + expect(editor.isFoldedAtScreenRow(1)).toBe(false) + }) + it('autoscrolls the content when dragging near the edge of the screen', async () => { const {component, editor} = buildComponent({width: 200, height: 200}) const {scroller} = component.refs diff --git a/src/text-editor-component.js b/src/text-editor-component.js index aef4b9b5431..5b59d0d240b 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -813,17 +813,21 @@ class TextEditorComponent { } didMouseDownOnLineNumberGutter (event) { - if (global.debug) debugger - const {model} = this.props - const {button, ctrlKey, shiftKey, metaKey} = event + const {target, button, ctrlKey, shiftKey, metaKey} = event // Only handle mousedown events for left mouse button if (button !== 0) return - const addOrRemoveSelection = metaKey || (ctrlKey && this.getPlatform() !== 'darwin') const clickedScreenRow = this.screenPositionForMouseEvent(event).row const startBufferRow = model.bufferPositionForScreenPosition([clickedScreenRow, 0]).row + + if (target.matches('.foldable .icon-right')) { + model.toggleFoldAtBufferRow(startBufferRow) + return + } + + const addOrRemoveSelection = metaKey || (ctrlKey && this.getPlatform() !== 'darwin') const endBufferRow = model.bufferPositionForScreenPosition([clickedScreenRow, Infinity]).row const clickedLineBufferRange = Range(Point(startBufferRow, 0), Point(endBufferRow + 1, 0)) From 68659d9698af283bfd4c3c91b264078bf79668eb Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 14 Mar 2017 15:48:04 -0600 Subject: [PATCH 124/403] When decorating a MarkerLayer, get its corresponding DisplayMarkerLayer This fixes 'folded' line number decorations. --- spec/text-editor-component-spec.js | 7 +++++++ src/decoration-manager.js | 1 + 2 files changed, 8 insertions(+) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index cfd0e1afd8d..91a9e0bdf5b 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -224,6 +224,13 @@ describe('TextEditorComponent', () => { expect(scroller.clientWidth).toBe(scroller.scrollWidth) }) + it('decorates the line numbers of folded lines', async () => { + const {component, element, editor} = buildComponent() + editor.foldBufferRow(1) + await component.getNextUpdatePromise() + expect(lineNumberNodeForScreenRow(component, 1).classList.contains('folded')).toBe(true) + }) + describe('focus', () => { it('focuses the hidden input element and adds the is-focused class when focused', async () => { assertDocumentFocused() diff --git a/src/decoration-manager.js b/src/decoration-manager.js index 7a99d5809d7..84278eb8f8a 100644 --- a/src/decoration-manager.js +++ b/src/decoration-manager.js @@ -200,6 +200,7 @@ class DecorationManager { if (markerLayer.isDestroyed()) { throw new Error('Cannot decorate a destroyed marker layer') } + markerLayer = this.displayLayer.getMarkerLayer(markerLayer.id) const decoration = new LayerDecoration(markerLayer, this, decorationParams) let layerDecorations = this.layerDecorationsByMarkerLayer.get(markerLayer) if (layerDecorations == null) { From c2206b88dac8158193b7743381f914628a96c677 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 14 Mar 2017 16:02:20 -0600 Subject: [PATCH 125/403] Destroy folds on fold marker click --- spec/text-editor-component-spec.js | 12 ++++++++++++ src/text-editor-component.js | 9 ++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 91a9e0bdf5b..685e8136147 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -934,6 +934,18 @@ describe('TextEditorComponent', () => { expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [5, 0]]) }) + it('destroys folds when clicking on their fold markers', async () => { + const {component, element, editor} = buildComponent() + editor.foldBufferRow(1) + await component.getNextUpdatePromise() + + const target = element.querySelector('.fold-marker') + const {clientX, clientY} = clientPositionForCharacter(component, 1, editor.lineLengthForScreenRow(1)) + component.didMouseDownOnContent({detail: 1, button: 0, target, clientX, clientY}) + expect(editor.isFoldedAtBufferRow(1)).toBe(false) + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + }) + it('autoscrolls the content when dragging near the edge of the screen', async () => { const {component, editor} = buildComponent({width: 200, height: 200}) const {scroller} = component.refs diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 5b59d0d240b..ed112a2be13 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -761,13 +761,20 @@ class TextEditorComponent { didMouseDownOnContent (event) { const {model} = this.props - const {button, detail, ctrlKey, shiftKey, metaKey} = event + const {target, button, detail, ctrlKey, shiftKey, metaKey} = event // Only handle mousedown events for left mouse button (or the middle mouse // button on Linux where it pastes the selection clipboard). if (!(button === 0 || (this.getPlatform() === 'linux' && button === 1))) return const screenPosition = this.screenPositionForMouseEvent(event) + + if (target.matches('.fold-marker')) { + const bufferPosition = model.bufferPositionForScreenPosition(screenPosition) + model.destroyFoldsIntersectingBufferRange(Range(bufferPosition, bufferPosition)) + return + } + const addOrRemoveSelection = metaKey || (ctrlKey && this.getPlatform() !== 'darwin') switch (detail) { From df4d52c89a46901166e2a803988b4de0d7a3a27e Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 14 Mar 2017 16:32:52 -0600 Subject: [PATCH 126/403] Use constrained scroll values --- src/text-editor-component.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index ed112a2be13..6967f95001e 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -941,8 +941,8 @@ class TextEditorComponent { const scaledDelta = scaleMouseDragAutoscrollDelta(yDelta) * yDirection const newScrollTop = this.constrainScrollTop(this.measurements.scrollTop + scaledDelta) if (newScrollTop !== this.measurements.scrollTop) { - this.measurements.scrollTop += scaledDelta - this.refs.scroller.scrollTop += scaledDelta + this.measurements.scrollTop = newScrollTop + this.refs.scroller.scrollTop = newScrollTop scrolled = true } } @@ -951,8 +951,8 @@ class TextEditorComponent { const scaledDelta = scaleMouseDragAutoscrollDelta(xDelta) * xDirection const newScrollLeft = this.constrainScrollLeft(this.measurements.scrollLeft + scaledDelta) if (newScrollLeft !== this.measurements.scrollLeft) { - this.measurements.scrollLeft += scaledDelta - this.refs.scroller.scrollLeft += scaledDelta + this.measurements.scrollLeft = newScrollLeft + this.refs.scroller.scrollLeft = newScrollLeft scrolled = true } } From 625990d22f55f89a7acf3af718ee80db3dcb24a9 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 14 Mar 2017 16:48:43 -0600 Subject: [PATCH 127/403] Null guard target check to keep tests simple --- src/text-editor-component.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 6967f95001e..cb8b93e5479 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -769,7 +769,7 @@ class TextEditorComponent { const screenPosition = this.screenPositionForMouseEvent(event) - if (target.matches('.fold-marker')) { + if (target && target.matches('.fold-marker')) { const bufferPosition = model.bufferPositionForScreenPosition(screenPosition) model.destroyFoldsIntersectingBufferRange(Range(bufferPosition, bufferPosition)) return @@ -829,7 +829,7 @@ class TextEditorComponent { const clickedScreenRow = this.screenPositionForMouseEvent(event).row const startBufferRow = model.bufferPositionForScreenPosition([clickedScreenRow, 0]).row - if (target.matches('.foldable .icon-right')) { + if (target && target.matches('.foldable .icon-right')) { model.toggleFoldAtBufferRow(startBufferRow) return } From 173cdcb372f40e7564bad963a90d3e823e29916a Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 14 Mar 2017 17:05:56 -0600 Subject: [PATCH 128/403] Cache rendered screen lines on component to avoid drifting from model The model may have screen lines that aren't yet rendered in the page, and we want to avoid referring to them on mouse clicks. --- src/text-editor-component.js | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index cb8b93e5479..ab5a17f3545 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -100,6 +100,7 @@ class TextEditorComponent { if (this.pendingAutoscroll) this.initiateAutoscroll() this.populateVisibleRowRange() const longestLineToMeasure = this.checkForNewLongestLine() + this.queryScreenLinesToRender() this.queryDecorationsToRender() etch.updateSync(this) @@ -293,8 +294,6 @@ class TextEditorComponent { const tileWidth = this.getContentWidth() const displayLayer = this.getModel().displayLayer - const screenLines = displayLayer.getScreenLines(startRow, endRow) - const tileNodes = new Array(this.getRenderedTileCount()) for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow += rowsPerTile) { @@ -313,7 +312,7 @@ class TextEditorComponent { width: tileWidth, top: this.topPixelPositionForRow(tileStartRow), lineHeight: this.measurements.lineHeight, - screenLines: screenLines.slice(tileStartRow - startRow, tileEndRow - startRow), + screenLines: this.renderedScreenLines.slice(tileStartRow - startRow, tileEndRow - startRow), lineDecorations, highlightDecorations, displayLayer, @@ -417,6 +416,17 @@ class TextEditorComponent { return process.platform } + queryScreenLinesToRender () { + this.renderedScreenLines = this.getModel().displayLayer.getScreenLines( + this.getRenderedStartRow(), + this.getRenderedEndRow() + ) + } + + renderedScreenLineForRow (row) { + return this.renderedScreenLines[row - this.getRenderedStartRow()] + } + queryDecorationsToRender () { this.decorationsToRender.lineNumbers.clear() this.decorationsToRender.lines.clear() @@ -1206,7 +1216,7 @@ class TextEditorComponent { this.horizontalPositionsToMeasure.forEach((columnsToMeasure, row) => { columnsToMeasure.sort((a, b) => a - b) - const screenLine = this.getModel().displayLayer.getScreenLine(row) + const screenLine = this.renderedScreenLineForRow(row) const lineNode = this.lineNodesByScreenLineId.get(screenLine.id) if (!lineNode) { @@ -1270,7 +1280,7 @@ class TextEditorComponent { pixelLeftForRowAndColumn (row, column) { if (column === 0) return 0 - const screenLine = this.getModel().displayLayer.getScreenLine(row) + const screenLine = this.renderedScreenLineForRow(row) return this.horizontalPixelPositionsByScreenLineId.get(screenLine.id).get(column) } @@ -1284,7 +1294,7 @@ class TextEditorComponent { const linesClientLeft = this.refs.lineTiles.getBoundingClientRect().left const targetClientLeft = linesClientLeft + Math.max(0, left) - const screenLine = this.getModel().displayLayer.getScreenLine(row) + const screenLine = this.renderedScreenLineForRow(row) const textNodes = this.textNodesByScreenLineId.get(screenLine.id) let containingTextNodeIndex From ebad2e66057f07c7f40b453ebbf221006fed84dd Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 14 Mar 2017 20:32:25 -0600 Subject: [PATCH 129/403] Implement detachment to eliminate spurious drag events --- src/text-editor-component.js | 13 ++++++++++++- src/text-editor-element.js | 4 ++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index ab5a17f3545..c24949b0766 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -599,7 +599,18 @@ class TextEditorComponent { } }) this.intersectionObserver.observe(this.element) - if (this.isVisible()) this.didShow() + if (this.isVisible()) { + this.didShow() + } else { + this.didHide() + } + } + } + + didDetach () { + if (this.attached) { + this.didHide() + this.attached = false } } diff --git a/src/text-editor-element.js b/src/text-editor-element.js index 58fcc33b5d6..1a36b9782de 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -13,6 +13,10 @@ class TextEditorElement extends HTMLElement { this.emitter.emit('did-attach') } + detachedCallback () { + this.getComponent().didDetach() + } + getModel () { return this.getComponent().getModel() } From f00941f299a3db7e7e5777256cf29e2b536e62fe Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 14 Mar 2017 22:45:32 -0600 Subject: [PATCH 130/403] Only create EditorComponent once per editor --- src/text-editor.coffee | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/text-editor.coffee b/src/text-editor.coffee index b826250f3c1..3fe1d40d0b4 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -3547,9 +3547,12 @@ class TextEditor extends Model # Get the Element for the editor. getElement: -> - TextEditorComponent ?= require('./text-editor-component') - new TextEditorComponent({model: this}) - @component.element + if @component? + @component.element + else + TextEditorComponent ?= require('./text-editor-component') + new TextEditorComponent({model: this}) + @component.element # Essential: Retrieves the greyed out placeholder of a mini editor. # From 758466c9af2a4355b30bc8f36552f668406d7dc9 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 14 Mar 2017 22:47:21 -0600 Subject: [PATCH 131/403] Make various tweaks to improve mini editors Still a ways to go, but this is a start toward getting the mini-editors to play nice with our existing styling. --- package.json | 2 +- spec/text-editor-component-spec.js | 15 ++++++- src/text-editor-component.js | 72 +++++++++++++++++++++--------- src/text-editor-element.js | 4 ++ 4 files changed, 70 insertions(+), 23 deletions(-) diff --git a/package.json b/package.json index 72eacad3442..b5be71b3074 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dedent": "^0.6.0", "devtron": "1.3.0", "element-resize-detector": "^1.1.10", - "etch": "^0.9.5", + "etch": "^0.10.0", "event-kit": "^2.3.0", "find-parent-dir": "^0.3.0", "first-mate": "7.0.4", diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 685e8136147..7e94ed810a4 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -231,6 +231,19 @@ describe('TextEditorComponent', () => { expect(lineNumberNodeForScreenRow(component, 1).classList.contains('folded')).toBe(true) }) + describe('mini editors', () => { + it('adds the mini attribute', () => { + const {element, editor} = buildComponent({mini: true}) + expect(element.hasAttribute('mini')).toBe(true) + }) + + it('does not render the gutter container', () => { + const {component, element, editor} = buildComponent({mini: true}) + expect(component.refs.gutterContainer).toBeUndefined() + expect(element.querySelector('gutter-container')).toBeNull() + }) + }) + describe('focus', () => { it('focuses the hidden input element and adds the is-focused class when focused', async () => { assertDocumentFocused() @@ -1241,7 +1254,7 @@ describe('TextEditorComponent', () => { function buildComponent (params = {}) { const buffer = new TextBuffer({text: SAMPLE_TEXT}) - const editor = new TextEditor({buffer}) + const editor = new TextEditor({buffer, mini: params.mini}) const component = new TextEditorComponent({ model: editor, rowsPerTile: params.rowsPerTile, diff --git a/src/text-editor-component.js b/src/text-editor-component.js index c24949b0766..0fbbd31a289 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -118,56 +118,79 @@ class TextEditorComponent { render () { const model = this.getModel() - let style + const style = { + overflow: 'hidden', + } if (!model.getAutoHeight() && !model.getAutoWidth()) { - style = {contain: 'strict'} + style.contain = 'strict' } + let attributes = null let className = 'editor' if (this.focused) { className += ' is-focused' } + if (model.isMini()) { + attributes = {mini: ''} + className += ' mini' + } - return $('atom-text-editor', { + const scrollerOverflowX = (model.isMini() || model.isSoftWrapped()) ? 'hidden' : 'auto' + const scrollerOverflowY = model.isMini() ? 'hidden' : 'auto' + + return $('atom-text-editor', + { className, + attributes, style, tabIndex: -1, on: {focus: this.didFocus} }, $.div( { - ref: 'scroller', - className: 'scroll-view', - on: {scroll: this.didScroll}, style: { - position: 'absolute', - contain: 'strict', - top: 0, - right: 0, - bottom: 0, - left: 0, - overflowX: model.isSoftWrapped() ? 'hidden' : 'auto', - overflowY: 'auto', + position: 'relative', + width: '100%', + height: '100%', backgroundColor: 'inherit' } }, $.div( { + ref: 'scroller', + className: 'scroll-view', + on: {scroll: this.didScroll}, style: { - isolate: 'content', - width: 'max-content', - height: 'max-content', + position: 'absolute', + contain: 'strict', + top: 0, + right: 0, + bottom: 0, + left: 0, + overflowX: scrollerOverflowX, + overflowY: scrollerOverflowY, backgroundColor: 'inherit' } }, - this.renderGutterContainer(), - this.renderContent() + $.div( + { + style: { + isolate: 'content', + width: 'max-content', + height: 'max-content', + backgroundColor: 'inherit' + } + }, + this.renderGutterContainer(), + this.renderContent() + ) ) ) ) } renderGutterContainer () { + if (this.props.model.isMini()) return null const props = {ref: 'gutterContainer', className: 'gutter-container'} if (this.measurements) { @@ -338,7 +361,8 @@ class TextEditorComponent { style: { position: 'absolute', contain: 'strict', - width, height + width, height, + backgroundColor: 'inherit' } }, tileNodes) } @@ -1132,6 +1156,8 @@ class TextEditorComponent { } measureEditorDimensions () { + if (!this.measurements) return false + let dimensionsChanged = false const scrollerHeight = this.refs.scroller.offsetHeight const scrollerWidth = this.refs.scroller.offsetWidth @@ -1210,7 +1236,11 @@ class TextEditorComponent { } measureGutterDimensions () { - this.measurements.lineNumberGutterWidth = this.refs.lineNumberGutter.offsetWidth + if (this.refs.lineNumberGutter) { + this.measurements.lineNumberGutterWidth = this.refs.lineNumberGutter.offsetWidth + } else { + this.measurements.lineNumberGutterWidth = 0 + } } requestHorizontalMeasurement (row, column) { diff --git a/src/text-editor-element.js b/src/text-editor-element.js index 1a36b9782de..38bedfe0ec8 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -49,6 +49,10 @@ class TextEditorElement extends HTMLElement { return this.getComponent().getScrollLeft() } + hasFocus () { + return this.getComponent().focused + } + getComponent () { if (!this.component) this.component = new TextEditorComponent({ element: this, From 5d82dcf87a14a49c6d647975c202f425a242442f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 15 Mar 2017 12:08:38 -0600 Subject: [PATCH 132/403] Wait for content width to update before autoscrolling horizontally --- spec/text-editor-component-spec.js | 13 +++++++++++++ src/text-editor-component.js | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 7e94ed810a4..942504131eb 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -391,6 +391,19 @@ describe('TextEditorComponent', () => { ) expect(scroller.scrollLeft).toBe(expectedScrollLeft) }) + + it('correctly autoscrolls after inserting a line that exceeds the current content width', async () => { + const {component, element, editor} = buildComponent() + const {scroller} = component.refs + element.style.width = component.getScrollWidth() + 'px' + await component.getNextUpdatePromise() + + editor.setCursorScreenPosition([0, Infinity]) + editor.insertText('x'.repeat(100)) + await component.getNextUpdatePromise() + + expect(scroller.scrollLeft).toBe(component.getScrollWidth() - scroller.clientWidth) + }) }) describe('line and line number decorations', () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 0fbbd31a289..c8248dccefd 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -107,11 +107,11 @@ class TextEditorComponent { this.measureHorizontalPositions() if (longestLineToMeasure) this.measureLongestLineWidth(longestLineToMeasure) - if (this.pendingAutoscroll) this.finalizeAutoscroll() this.updateAbsolutePositionedDecorations() etch.updateSync(this) + if (this.pendingAutoscroll) this.finalizeAutoscroll() this.currentFrameLineNumberGutterProps = null } From 5c7208751f5afdc5bcf14eed682e022038c7673e Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 15 Mar 2017 12:57:46 -0600 Subject: [PATCH 133/403] Correctly autoscroll if a horizontal scrollbar appears in the same frame --- spec/text-editor-component-spec.js | 15 +++++++++++ src/text-editor-component.js | 43 +++++++++++++++++++++--------- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 942504131eb..31498e4a0a0 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -404,6 +404,21 @@ describe('TextEditorComponent', () => { expect(scroller.scrollLeft).toBe(component.getScrollWidth() - scroller.clientWidth) }) + + it('accounts for the presence of horizontal scrollbars that appear during the same frame as the autoscroll', async () => { + const {component, element, editor} = buildComponent() + const {scroller} = component.refs + element.style.height = component.getScrollHeight() + 'px' + element.style.width = component.getScrollWidth() + 'px' + await component.getNextUpdatePromise() + + editor.setCursorScreenPosition([10, Infinity]) + editor.insertText('\n\n' + 'x'.repeat(100)) + await component.getNextUpdatePromise() + + expect(scroller.scrollTop).toBe(component.getScrollHeight() - scroller.clientHeight) + expect(scroller.scrollLeft).toBe(component.getScrollWidth() - scroller.clientWidth) + }) }) describe('line and line number decorations', () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index c8248dccefd..2deb4d803a4 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -46,7 +46,6 @@ class TextEditorComponent { this.textNodesByScreenLineId = new Map() this.pendingAutoscroll = null this.autoscrollTop = null - this.contentDimensionsChanged = false this.previousScrollWidth = 0 this.previousScrollHeight = 0 this.lastKeydown = null @@ -96,7 +95,6 @@ class TextEditorComponent { } this.horizontalPositionsToMeasure.clear() - if (this.contentDimensionsChanged) this.measureClientDimensions() if (this.pendingAutoscroll) this.initiateAutoscroll() this.populateVisibleRowRange() const longestLineToMeasure = this.checkForNewLongestLine() @@ -111,10 +109,34 @@ class TextEditorComponent { etch.updateSync(this) + // If scrollHeight or scrollWidth changed, we may have shown or hidden + // scrollbars, affecting the clientWidth or clientHeight + if (this.checkIfScrollDimensionsChanged()) { + this.measureClientDimensions() + // If the clientHeight changed, our previous vertical autoscroll may have + // been off by the height of the horizontal scrollbar. If we *still* need + // to autoscroll, just re-render the frame. + if (this.pendingAutoscroll && this.initiateAutoscroll()) { + this.updateSync() + return + } + } if (this.pendingAutoscroll) this.finalizeAutoscroll() this.currentFrameLineNumberGutterProps = null } + checkIfScrollDimensionsChanged () { + const scrollHeight = this.getScrollHeight() + const scrollWidth = this.getScrollWidth() + if (scrollHeight !== this.previousScrollHeight || scrollWidth !== this.previousScrollWidth) { + this.previousScrollHeight = scrollHeight + this.previousScrollWidth = scrollWidth + return true + } else { + return false + } + } + render () { const model = this.getModel() @@ -272,12 +294,6 @@ class TextEditorComponent { if (this.measurements) { const contentWidth = this.getContentWidth() const scrollHeight = this.getScrollHeight() - if (contentWidth !== this.previousScrollWidth || scrollHeight !== this.previousScrollHeight) { - this.contentDimensionsChanged = true - this.previousScrollWidth = contentWidth - this.previousScrollHeight = scrollHeight - } - const width = contentWidth + 'px' const height = scrollHeight + 'px' style.width = width @@ -1055,21 +1071,27 @@ class TextEditorComponent { if (desiredScrollBottom > this.getScrollBottom()) { this.autoscrollTop = desiredScrollBottom - this.measurements.clientHeight this.measurements.scrollTop = this.autoscrollTop + return true } if (desiredScrollTop < this.getScrollTop()) { this.autoscrollTop = desiredScrollTop this.measurements.scrollTop = this.autoscrollTop + return true } } else { if (desiredScrollTop < this.getScrollTop()) { this.autoscrollTop = desiredScrollTop this.measurements.scrollTop = this.autoscrollTop + return true } if (desiredScrollBottom > this.getScrollBottom()) { this.autoscrollTop = desiredScrollBottom - this.measurements.clientHeight this.measurements.scrollTop = this.autoscrollTop + return true } } + + return false } finalizeAutoscroll () { @@ -1187,19 +1209,14 @@ class TextEditorComponent { } measureClientDimensions () { - let clientDimensionsChanged = false const {clientHeight, clientWidth} = this.refs.scroller if (clientHeight !== this.measurements.clientHeight) { this.measurements.clientHeight = clientHeight - clientDimensionsChanged = true } if (clientWidth !== this.measurements.clientWidth) { this.measurements.clientWidth = clientWidth this.getModel().setWidth(clientWidth - this.getGutterContainerWidth(), true) - clientDimensionsChanged = true } - this.contentDimensionsChanged = false - return clientDimensionsChanged } measureCharacterDimensions () { From 1427dbf540dc349ac43bca25cb1171905132f252 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 15 Mar 2017 20:35:25 -0600 Subject: [PATCH 134/403] Make lines extend across the entire width of the scroller This ensures line decorations render properly, even when the content is narrower than the editor. --- spec/text-editor-component-spec.js | 11 ++++++++++- src/text-editor-component.js | 5 ++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 31498e4a0a0..8350251a96b 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -231,6 +231,15 @@ describe('TextEditorComponent', () => { expect(lineNumberNodeForScreenRow(component, 1).classList.contains('folded')).toBe(true) }) + it('makes lines at least as wide as the scroller', async () => { + const {component, element, editor} = buildComponent() + const {scroller, gutterContainer} = component.refs + editor.setText('a') + await component.getNextUpdatePromise() + + expect(element.querySelector('.line').offsetWidth).toBe(scroller.offsetWidth - gutterContainer.offsetWidth) + }) + describe('mini editors', () => { it('adds the mini attribute', () => { const {element, editor} = buildComponent({mini: true}) @@ -395,7 +404,7 @@ describe('TextEditorComponent', () => { it('correctly autoscrolls after inserting a line that exceeds the current content width', async () => { const {component, element, editor} = buildComponent() const {scroller} = component.refs - element.style.width = component.getScrollWidth() + 'px' + element.style.width = component.getGutterContainerWidth() + component.measurements.longestLineWidth + 'px' await component.getNextUpdatePromise() editor.setCursorScreenPosition([0, Infinity]) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 2deb4d803a4..1c0511d7043 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1496,7 +1496,10 @@ class TextEditorComponent { if (this.getModel().isSoftWrapped()) { return this.getClientWidth() - this.getGutterContainerWidth() } else { - return Math.round(this.measurements.longestLineWidth + this.measurements.baseCharacterWidth) + return Math.max( + Math.round(this.measurements.longestLineWidth + this.measurements.baseCharacterWidth), + this.measurements.scrollerWidth - this.getGutterContainerWidth() + ) } } From 82feef9f685a56585f920ab27e48d9996b45a2cf Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 15 Mar 2017 20:46:46 -0600 Subject: [PATCH 135/403] Don't render cursor line decorations in mini editors --- spec/text-editor-component-spec.js | 13 +++++++++++++ src/text-editor.coffee | 16 +++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 8350251a96b..008b804f560 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -251,6 +251,19 @@ describe('TextEditorComponent', () => { expect(component.refs.gutterContainer).toBeUndefined() expect(element.querySelector('gutter-container')).toBeNull() }) + + it('does not render line decorations for the cursor line', async () => { + const {component, element, editor} = buildComponent({mini: true}) + expect(element.querySelector('.line').classList.contains('cursor-line')).toBe(false) + + editor.update({mini: false}) + await component.getNextUpdatePromise() + expect(element.querySelector('.line').classList.contains('cursor-line')).toBe(true) + + editor.update({mini: true}) + await component.getNextUpdatePromise() + expect(element.querySelector('.line').classList.contains('cursor-line')).toBe(false) + }) }) describe('focus', () => { diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 3fe1d40d0b4..4664168da84 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -200,9 +200,7 @@ class TextEditor extends Model @decorationManager = new DecorationManager(@displayLayer) @decorateMarkerLayer(@selectionsMarkerLayer, type: 'cursor') - @decorateMarkerLayer(@selectionsMarkerLayer, type: 'line-number', class: 'cursor-line') - @decorateMarkerLayer(@selectionsMarkerLayer, type: 'line-number', class: 'cursor-line-no-selection', onlyHead: true, onlyEmpty: true) - @decorateMarkerLayer(@selectionsMarkerLayer, type: 'line', class: 'cursor-line', onlyEmpty: true) + @decorateCursorLine() unless @isMini() @decorateMarkerLayer(@displayLayer.foldsMarkerLayer, {type: 'line-number', class: 'folded'}) @@ -225,6 +223,13 @@ class TextEditor extends Model priority: 0 visible: lineNumberGutterVisible + decorateCursorLine: -> + @cursorLineDecorations = [ + @decorateMarkerLayer(@selectionsMarkerLayer, type: 'line', class: 'cursor-line', onlyEmpty: true), + @decorateMarkerLayer(@selectionsMarkerLayer, type: 'line-number', class: 'cursor-line'), + @decorateMarkerLayer(@selectionsMarkerLayer, type: 'line-number', class: 'cursor-line-no-selection', onlyHead: true, onlyEmpty: true) + ] + doBackgroundWork: (deadline) => if @displayLayer.doBackgroundWork(deadline) @presenter?.updateVerticalDimensions() @@ -297,6 +302,11 @@ class TextEditor extends Model displayLayerParams.invisibles = @getInvisibles() displayLayerParams.softWrapColumn = @getSoftWrapColumn() displayLayerParams.showIndentGuides = @doesShowIndentGuide() + if @mini + decoration.destroy() for decoration in @cursorLineDecorations + @cursorLineDecorations = null + else + @decorateCursorLine() when 'placeholderText' if value isnt @placeholderText From 401434858babd03f9dbd6b9f71102ef70991456b Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 15 Mar 2017 21:05:56 -0600 Subject: [PATCH 136/403] Gracefully handle focus prior to detecting the editor has become visible --- spec/text-editor-component-spec.js | 14 ++++++++++++++ src/text-editor-component.js | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 008b804f560..1fcb8b3c775 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -312,6 +312,20 @@ describe('TextEditorComponent', () => { jasmine.attachToDOM(parent) expect(document.activeElement).toBe(component.refs.hiddenInput) }) + + it('gracefully handles a focus event that occurs prior to detecting the element has become visible', async () => { + assertDocumentFocused() + + const {component, element, editor} = buildComponent({attach: false}) + element.style.display = 'none' + jasmine.attachToDOM(element) + element.style.display = 'block' + console.log('focus in test'); + element.focus() + await component.getNextUpdatePromise() + + expect(document.activeElement).toBe(component.refs.hiddenInput) + }) }) describe('autoscroll on cursor movement', () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 1c0511d7043..45a1092f4e2 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -676,6 +676,12 @@ class TextEditorComponent { // against that case. if (!this.attached) this.didAttach() + // The element can be focused before the intersection observer detects that + // it has been shown for the first time. If this element is being focused, + // it is necessarily visible, so we call `didShow` to ensure the hidden + // input is rendered before we try to shift focus to it. + if (!this.visible) this.didShow() + if (!this.focused) { this.focused = true this.scheduleUpdate() From b152bfd9c638a955efb1311a044d876e5f943149 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 16 Mar 2017 21:02:33 -0600 Subject: [PATCH 137/403] Guard against unfocused window in beforeEach --- spec/text-editor-component-spec.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 1fcb8b3c775..c8120757bcb 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -267,9 +267,11 @@ describe('TextEditorComponent', () => { }) describe('focus', () => { - it('focuses the hidden input element and adds the is-focused class when focused', async () => { + beforeEach(() => { assertDocumentFocused() + }) + it('focuses the hidden input element and adds the is-focused class when focused', async () => { const {component, element, editor} = buildComponent() const {hiddenInput} = component.refs @@ -290,8 +292,6 @@ describe('TextEditorComponent', () => { }) it('updates the component when the hidden input is focused directly', async () => { - assertDocumentFocused() - const {component, element, editor} = buildComponent() const {hiddenInput} = component.refs expect(element.classList.contains('is-focused')).toBe(false) @@ -303,8 +303,6 @@ describe('TextEditorComponent', () => { }) it('gracefully handles a focus event that occurs prior to the attachedCallback of the element', () => { - assertDocumentFocused() - const {component, element, editor} = buildComponent({attach: false}) const parent = document.createElement('text-editor-component-test-element') parent.appendChild(element) @@ -314,8 +312,6 @@ describe('TextEditorComponent', () => { }) it('gracefully handles a focus event that occurs prior to detecting the element has become visible', async () => { - assertDocumentFocused() - const {component, element, editor} = buildComponent({attach: false}) element.style.display = 'none' jasmine.attachToDOM(element) From 369818b4759c2171579a9fc450582c5d3b8ad48c Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 16 Mar 2017 21:03:48 -0600 Subject: [PATCH 138/403] Emit editor blur events as if no hidden input existed --- spec/text-editor-component-spec.js | 14 ++++++++++++++ src/text-editor-component.js | 12 +++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index c8120757bcb..e4f996bdf77 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -322,6 +322,20 @@ describe('TextEditorComponent', () => { expect(document.activeElement).toBe(component.refs.hiddenInput) }) + + it('emits blur events only when focus shifts to something other than the editor itself or its hidden input', () => { + const {element} = buildComponent() + + let blurEventCount = 0 + element.addEventListener('blur', () => blurEventCount++) + + element.focus() + expect(blurEventCount).toBe(0) + element.focus() + expect(blurEventCount).toBe(0) + document.body.focus() + expect(blurEventCount).toBe(1) + }) }) describe('autoscroll on cursor movement', () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 45a1092f4e2..aa24cbd6a8c 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -166,7 +166,10 @@ class TextEditorComponent { attributes, style, tabIndex: -1, - on: {focus: this.didFocus} + on: { + focus: this.didFocus, + blur: this.didBlur + } }, $.div( { @@ -707,10 +710,17 @@ class TextEditorComponent { } } + didBlur (event) { + if (event.relatedTarget === this.refs.hiddenInput) { + event.stopImmediatePropagation() + } + } + didBlurHiddenInput (event) { if (this.element !== event.relatedTarget && !this.element.contains(event.relatedTarget)) { this.focused = false this.scheduleUpdate() + this.element.dispatchEvent(new FocusEvent(event.type, event)) } } From 88b30bc4dcaaa6adbcaf8827144f5eb52c077c03 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 16 Mar 2017 21:39:39 -0600 Subject: [PATCH 139/403] Support autoHeight and autoWidth settings --- spec/text-editor-component-spec.js | 41 +++++++++++++++++++++++++----- src/text-editor-component.js | 9 +++++++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index e4f996bdf77..230cfd3995c 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -27,7 +27,7 @@ describe('TextEditorComponent', () => { }) it('renders lines and line numbers for the visible region', async () => { - const {component, element, editor} = buildComponent({rowsPerTile: 3}) + const {component, element, editor} = buildComponent({rowsPerTile: 3, autoHeight: false}) expect(element.querySelectorAll('.line-number').length).toBe(13) expect(element.querySelectorAll('.line').length).toBe(13) @@ -87,7 +87,7 @@ describe('TextEditorComponent', () => { }) it('honors the scrollPastEnd option by adding empty space equivalent to the clientHeight to the end of the content area', async () => { - const {component, element, editor} = buildComponent() + const {component, element, editor} = buildComponent({autoHeight: false, autoWidth: false}) const {scroller} = component.refs await editor.update({scrollPastEnd: true}) @@ -240,6 +240,23 @@ describe('TextEditorComponent', () => { expect(element.querySelector('.line').offsetWidth).toBe(scroller.offsetWidth - gutterContainer.offsetWidth) }) + it('resizes based on the content when the autoHeight and/or autoWidth options are true', async () => { + const {component, element, editor} = buildComponent({autoHeight: true, autoWidth: true}) + const initialWidth = element.offsetWidth + const initialHeight = element.offsetHeight + expect(initialWidth).toBe(component.refs.scroller.scrollWidth) + expect(initialHeight).toBe(component.refs.scroller.scrollHeight) + editor.setCursorScreenPosition([6, Infinity]) + editor.insertText('x'.repeat(50)) + await component.getNextUpdatePromise() + expect(element.offsetWidth).toBe(component.refs.scroller.scrollWidth) + expect(element.offsetWidth).toBeGreaterThan(initialWidth) + editor.insertText('\n'.repeat(5)) + await component.getNextUpdatePromise() + expect(element.offsetHeight).toBe(component.refs.scroller.scrollHeight) + expect(element.offsetHeight).toBeGreaterThan(initialHeight) + }) + describe('mini editors', () => { it('adds the mini attribute', () => { const {element, editor} = buildComponent({mini: true}) @@ -364,7 +381,7 @@ describe('TextEditorComponent', () => { }) it('does not vertically autoscroll by more than half of the visible lines if the editor is shorter than twice the scroll margin', async () => { - const {component, element, editor} = buildComponent() + const {component, element, editor} = buildComponent({autoHeight: false}) const {scroller} = component.refs element.style.height = 5.5 * component.measurements.lineHeight + 'px' await component.getNextUpdatePromise() @@ -420,7 +437,7 @@ describe('TextEditorComponent', () => { }) it('does not horizontally autoscroll by more than half of the visible "base-width" characters if the editor is narrower than twice the scroll margin', async () => { - const {component, element, editor} = buildComponent() + const {component, element, editor} = buildComponent({autoHeight: false}) const {scroller, gutterContainer} = component.refs await setEditorWidthInCharacters(component, 1.5 * editor.horizontalScrollMargin) @@ -1328,15 +1345,25 @@ describe('TextEditorComponent', () => { function buildComponent (params = {}) { const buffer = new TextBuffer({text: SAMPLE_TEXT}) - const editor = new TextEditor({buffer, mini: params.mini}) + const editorParams = {buffer} + if (params.height != null) params.autoHeight = false + if (params.width != null) params.autoHeight = false + if (params.mini != null) editorParams.mini = params.mini + if (params.autoHeight != null) editorParams.autoHeight = params.autoHeight + if (params.autoWidth != null) editorParams.autoWidth = params.autoWidth + const editor = new TextEditor(editorParams) const component = new TextEditorComponent({ model: editor, rowsPerTile: params.rowsPerTile, updatedSynchronously: false }) const {element} = component - element.style.width = params.width ? params.width + 'px' : '800px' - element.style.height = params.height ? params.height + 'px' : '600px' + if (!editor.getAutoHeight()) { + element.style.height = params.height ? params.height + 'px' : '600px' + } + if (!editor.getAutoWidth()) { + element.style.width = params.width ? params.width + 'px' : '800px' + } if (params.attach !== false) jasmine.attachToDOM(element) return {component, element, editor} } diff --git a/src/text-editor-component.js b/src/text-editor-component.js index aa24cbd6a8c..547c2886175 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -147,6 +147,15 @@ class TextEditorComponent { style.contain = 'strict' } + if (this.measurements) { + if (model.getAutoHeight()) { + style.height = this.getScrollHeight() + 'px' + } + if (model.getAutoWidth()) { + style.width = this.getScrollWidth() + 'px' + } + } + let attributes = null let className = 'editor' if (this.focused) { From 36f5262f407f3070becef34d28c884433c1ab354 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 16 Mar 2017 21:48:07 -0600 Subject: [PATCH 140/403] Honor the isLineNumberGutterVisible option --- spec/text-editor-component-spec.js | 6 ++++++ src/text-editor-component.js | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 230cfd3995c..a842c216161 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -257,6 +257,11 @@ describe('TextEditorComponent', () => { expect(element.offsetHeight).toBeGreaterThan(initialHeight) }) + it('supports the isLineNumberGutterVisible parameter', () => { + const {component, element, editor} = buildComponent({lineNumberGutterVisible: false}) + expect(element.querySelector('.line-number')).toBe(null) + }) + describe('mini editors', () => { it('adds the mini attribute', () => { const {element, editor} = buildComponent({mini: true}) @@ -1351,6 +1356,7 @@ function buildComponent (params = {}) { if (params.mini != null) editorParams.mini = params.mini if (params.autoHeight != null) editorParams.autoHeight = params.autoHeight if (params.autoWidth != null) editorParams.autoWidth = params.autoWidth + if (params.lineNumberGutterVisible != null) editorParams.lineNumberGutterVisible = params.lineNumberGutterVisible const editor = new TextEditor(editorParams) const component = new TextEditorComponent({ model: editor, diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 547c2886175..8ea01dce70e 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -240,11 +240,14 @@ class TextEditorComponent { } renderLineNumberGutter () { + const model = this.getModel() + + if (!model.isLineNumberGutterVisible()) return null + if (this.currentFrameLineNumberGutterProps) { return $(LineNumberGutterComponent, this.currentFrameLineNumberGutterProps) } - const model = this.getModel() const maxLineNumberDigits = Math.max(2, model.getLineCount().toString().length) if (this.measurements) { From 4e001399658f7b2214cf4645bc3826444eeeddca Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 16 Mar 2017 21:59:08 -0600 Subject: [PATCH 141/403] Support placeholderText parameter --- spec/text-editor-component-spec.js | 18 ++++++++++++------ src/text-editor-component.js | 14 +++++++++++++- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index a842c216161..8243a874795 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -262,6 +262,13 @@ describe('TextEditorComponent', () => { expect(element.querySelector('.line-number')).toBe(null) }) + it('supports the placeholderText parameter', () => { + const placeholderText = 'Placeholder Test' + const {component} = buildComponent({placeholderText, text: ''}) + const emptyLineSpace = ' ' + expect(component.refs.content.textContent).toBe(emptyLineSpace + placeholderText) + }) + describe('mini editors', () => { it('adds the mini attribute', () => { const {element, editor} = buildComponent({mini: true}) @@ -1349,14 +1356,13 @@ describe('TextEditorComponent', () => { }) function buildComponent (params = {}) { - const buffer = new TextBuffer({text: SAMPLE_TEXT}) + const text = params.text != null ? params.text : SAMPLE_TEXT + const buffer = new TextBuffer({text}) const editorParams = {buffer} if (params.height != null) params.autoHeight = false - if (params.width != null) params.autoHeight = false - if (params.mini != null) editorParams.mini = params.mini - if (params.autoHeight != null) editorParams.autoHeight = params.autoHeight - if (params.autoWidth != null) editorParams.autoWidth = params.autoWidth - if (params.lineNumberGutterVisible != null) editorParams.lineNumberGutterVisible = params.lineNumberGutterVisible + for (const paramName of ['mini', 'autoHeight', 'autoWidth', 'lineNumberGutterVisible', 'placeholderText']) { + if (params[paramName] != null) editorParams[paramName] = params[paramName] + } const editor = new TextEditor(editorParams) const component = new TextEditorComponent({ model: editor, diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 8ea01dce70e..413f5a94b18 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -315,7 +315,8 @@ class TextEditorComponent { style.height = height children = [ this.renderCursorsAndInput(width, height), - this.renderLineTiles(width, height) + this.renderLineTiles(width, height), + this.renderPlaceholderText() ] } else { children = $.div({ref: 'characterMeasurementLine', className: 'line'}, @@ -427,6 +428,17 @@ class TextEditorComponent { }, children) } + renderPlaceholderText () { + const {model} = this.props + if (model.isEmpty()) { + const placeholderText = model.getPlaceholderText() + if (placeholderText != null) { + return $.div({className: 'placeholder-text'}, placeholderText) + } + } + return null + } + renderHiddenInput () { let top, left if (this.hiddenInputPosition) { From 602315981971971743fea836e21f442f2a5194a1 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 17 Mar 2017 16:38:59 -0600 Subject: [PATCH 142/403] Add highlight decoration flashing --- spec/text-editor-component-spec.js | 70 ++++++++++++++++++++++++++++++ src/decoration.coffee | 3 +- src/text-editor-component.js | 49 ++++++++++++++++++--- 3 files changed, 113 insertions(+), 9 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 8243a874795..8cab5b6455c 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -714,6 +714,76 @@ describe('TextEditorComponent', () => { expect(Math.round(region3Rect.right)).toBe(clientLeftForCharacter(component, 5, 4)) } }) + + it('can flash highlight decorations', async () => { + const {component, element, editor} = buildComponent({rowsPerTile: 3, height: 200}) + const marker = editor.markScreenRange([[2, 4], [3, 4]]) + const decoration = editor.decorateMarker(marker, {type: 'highlight', class: 'a'}) + decoration.flash('b', 10) + + // Flash on initial appearence of highlight + await component.getNextUpdatePromise() + const highlights = element.querySelectorAll('.highlight.a') + expect(highlights.length).toBe(2) // split across 2 tiles + + expect(highlights[0].classList.contains('b')).toBe(true) + expect(highlights[1].classList.contains('b')).toBe(true) + + await conditionPromise(() => + !highlights[0].classList.contains('b') && + !highlights[1].classList.contains('b') + ) + + // Don't flash on next update if another flash wasn't requested + component.refs.scroller.scrollTop = 100 + await component.getNextUpdatePromise() + expect(highlights[0].classList.contains('b')).toBe(false) + expect(highlights[1].classList.contains('b')).toBe(false) + + // Flash existing highlight + decoration.flash('c', 100) + await component.getNextUpdatePromise() + expect(highlights[0].classList.contains('c')).toBe(true) + expect(highlights[1].classList.contains('c')).toBe(true) + + // Add second flash class + decoration.flash('d', 100) + await component.getNextUpdatePromise() + expect(highlights[0].classList.contains('c')).toBe(true) + expect(highlights[1].classList.contains('c')).toBe(true) + expect(highlights[0].classList.contains('d')).toBe(true) + expect(highlights[1].classList.contains('d')).toBe(true) + + await conditionPromise(() => + !highlights[0].classList.contains('c') && + !highlights[1].classList.contains('c') && + !highlights[0].classList.contains('d') && + !highlights[1].classList.contains('d') + ) + + // Flashing the same class again before the first flash completes + // removes the flash class and adds it back on the next frame to ensure + // CSS transitions apply to the second flash. + decoration.flash('e', 100) + await component.getNextUpdatePromise() + expect(highlights[0].classList.contains('e')).toBe(true) + expect(highlights[1].classList.contains('e')).toBe(true) + + decoration.flash('e', 100) + await component.getNextUpdatePromise() + expect(highlights[0].classList.contains('e')).toBe(false) + expect(highlights[1].classList.contains('e')).toBe(false) + + await conditionPromise(() => + highlights[0].classList.contains('e') && + highlights[1].classList.contains('e') + ) + + await conditionPromise(() => + !highlights[0].classList.contains('e') && + !highlights[1].classList.contains('e') + ) + }) }) describe('mouse input', () => { diff --git a/src/decoration.coffee b/src/decoration.coffee index 5d406e17c53..7be15d9f5bf 100644 --- a/src/decoration.coffee +++ b/src/decoration.coffee @@ -171,8 +171,7 @@ class Decoration true flash: (klass, duration=500) -> - @properties.flashCount ?= 0 - @properties.flashCount++ + @properties.flashRequested = true @properties.flashClass = klass @properties.flashDuration = duration @decorationManager.emitDidUpdateDecorations() diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 413f5a94b18..7f8a97597b8 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -575,6 +575,10 @@ class TextEditorComponent { addHighlightDecorationToMeasure(decoration, screenRange) { screenRange = constrainRangeToRows(screenRange, this.getRenderedStartRow(), this.getRenderedEndRow()) if (screenRange.isEmpty()) return + + const {class: className, flashRequested, flashClass, flashDuration} = decoration + decoration.flashRequested = false + let tileStartRow = this.tileStartRowForRow(screenRange.start.row) const rowsPerTile = this.getRowsPerTile() @@ -587,7 +591,11 @@ class TextEditorComponent { tileHighlights = [] this.decorationsToMeasure.highlights.set(tileStartRow, tileHighlights) } - tileHighlights.push({decoration, screenRange: screenRangeInTile}) + + tileHighlights.push({ + screenRange: screenRangeInTile, + className, flashRequested, flashClass, flashDuration + }) this.requestHorizontalMeasurement(screenRangeInTile.start.row, screenRangeInTile.start.column) this.requestHorizontalMeasurement(screenRangeInTile.end.row, screenRangeInTile.end.column) @@ -1780,6 +1788,7 @@ class LinesTileComponent { highlightDecorations[i] ) children[i] = $(HighlightComponent, highlightProps) + highlightDecorations[i].flashRequested = false } } @@ -1846,7 +1855,8 @@ class LinesTileComponent { for (let i = 0, length = oldProps.highlightDecorations.length; i < length; i++) { const oldHighlight = oldProps.highlightDecorations[i] const newHighlight = newProps.highlightDecorations[i] - if (oldHighlight.decoration.class !== newHighlight.decoration.class) return true + if (oldHighlight.className !== newHighlight.className) return true + if (newHighlight.flashRequested) return true if (oldHighlight.startPixelLeft !== newHighlight.startPixelLeft) return true if (oldHighlight.endPixelLeft !== newHighlight.endPixelLeft) return true if (!oldHighlight.screenRange.isEqual(newHighlight.screenRange)) return true @@ -1935,17 +1945,43 @@ class HighlightComponent { constructor (props) { this.props = props etch.initialize(this) + if (this.props.flashRequested) this.performFlash() } - update (props) { - this.props = props + update (newProps) { + this.props = newProps etch.updateSync(this) + if (newProps.flashRequested) this.performFlash() + } + + performFlash () { + const {flashClass, flashDuration} = this.props + + const addAndRemoveFlashClass = () => { + this.element.classList.add(flashClass) + + if (!this.timeoutsByClassName) { + this.timeoutsByClassName = new Map() + } else if (this.timeoutsByClassName.has(flashClass)) { + window.clearTimeout(this.timeoutsByClassName.get(flashClass)) + } + this.timeoutsByClassName.set(flashClass, window.setTimeout(() => { + this.element.classList.remove(flashClass) + }, flashDuration)) + } + + if (this.element.classList.contains(flashClass)) { + this.element.classList.remove(flashClass) + window.requestAnimationFrame(addAndRemoveFlashClass) + } else { + addAndRemoveFlashClass() + } } render () { let {startPixelTop, endPixelTop} = this.props const { - decoration, screenRange, parentTileTop, lineHeight, + className, screenRange, parentTileTop, lineHeight, startPixelLeft, endPixelLeft, } = this.props startPixelTop -= parentTileTop @@ -2007,8 +2043,7 @@ class HighlightComponent { } } - const className = 'highlight ' + decoration.class - return $.div({className}, children) + return $.div({className: 'highlight ' + className}, children) } } From bef043a8ad7a9d87581f8b99d480df3f9b0001ac Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 17 Mar 2017 16:44:17 -0600 Subject: [PATCH 143/403] Refactor highlight flashing --- src/text-editor-component.js | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 7f8a97597b8..52688ab46bc 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1956,26 +1956,22 @@ class HighlightComponent { performFlash () { const {flashClass, flashDuration} = this.props - - const addAndRemoveFlashClass = () => { + if (!this.timeoutsByClassName) this.timeoutsByClassName = new Map() + + // If a flash of this class is already in progress, clear it early and + // flash again on the next frame to ensure CSS transitions apply to the + // second flash. + if (this.timeoutsByClassName.has(flashClass)) { + window.clearTimeout(this.timeoutsByClassName.get(flashClass)) + this.timeoutsByClassName.delete(flashClass) + this.element.classList.remove(flashClass) + requestAnimationFrame(() => this.performFlash()) + } else { this.element.classList.add(flashClass) - - if (!this.timeoutsByClassName) { - this.timeoutsByClassName = new Map() - } else if (this.timeoutsByClassName.has(flashClass)) { - window.clearTimeout(this.timeoutsByClassName.get(flashClass)) - } this.timeoutsByClassName.set(flashClass, window.setTimeout(() => { this.element.classList.remove(flashClass) }, flashDuration)) } - - if (this.element.classList.contains(flashClass)) { - this.element.classList.remove(flashClass) - window.requestAnimationFrame(addAndRemoveFlashClass) - } else { - addAndRemoveFlashClass() - } } render () { From 90c326b985c7c2e255472114439872f00f44432b Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 17 Mar 2017 17:17:18 -0600 Subject: [PATCH 144/403] Fix clearing of marker-specific properties for layer decorations --- spec/text-editor-component-spec.js | 24 ++++++++++++++++++++++++ src/decoration-manager.js | 3 ++- src/layer-decoration.coffee | 3 ++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 8cab5b6455c..9967901ceea 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -784,6 +784,30 @@ describe('TextEditorComponent', () => { !highlights[1].classList.contains('e') ) }) + + it('supports layer decorations', async () => { + const {component, element, editor} = buildComponent({rowsPerTile: 12}) + const markerLayer = editor.addMarkerLayer() + const marker1 = markerLayer.markScreenRange([[2, 4], [3, 4]]) + const marker2 = markerLayer.markScreenRange([[5, 6], [7, 8]]) + const decoration = editor.decorateMarkerLayer(markerLayer, {type: 'highlight', class: 'a'}) + await component.getNextUpdatePromise() + + const highlights = element.querySelectorAll('.highlight') + expect(highlights[0].classList.contains('a')).toBe(true) + expect(highlights[1].classList.contains('a')).toBe(true) + + decoration.setPropertiesForMarker(marker1, {type: 'highlight', class: 'b'}) + await component.getNextUpdatePromise() + expect(highlights[0].classList.contains('b')).toBe(true) + expect(highlights[1].classList.contains('a')).toBe(true) + + decoration.setPropertiesForMarker(marker1, null) + decoration.setPropertiesForMarker(marker2, {type: 'highlight', class: 'c'}) + await component.getNextUpdatePromise() + expect(highlights[0].classList.contains('a')).toBe(true) + expect(highlights[1].classList.contains('c')).toBe(true) + }) }) describe('mouse input', () => { diff --git a/src/decoration-manager.js b/src/decoration-manager.js index 84278eb8f8a..ec8b8b68429 100644 --- a/src/decoration-manager.js +++ b/src/decoration-manager.js @@ -100,7 +100,8 @@ class DecorationManager { if (layerDecorations) { layerDecorations.forEach((layerDecoration) => { - decorationPropertiesForMarker.push(layerDecoration.getPropertiesForMarker(marker) || layerDecoration.getProperties()) + const properties = layerDecoration.getPropertiesForMarker(marker) || layerDecoration.getProperties() + decorationPropertiesForMarker.push(properties) }) } diff --git a/src/layer-decoration.coffee b/src/layer-decoration.coffee index 03be59b14f5..e20921b4d65 100644 --- a/src/layer-decoration.coffee +++ b/src/layer-decoration.coffee @@ -53,10 +53,11 @@ class LayerDecoration setPropertiesForMarker: (marker, properties) -> return if @destroyed @overridePropertiesByMarker ?= new Map() + marker = @markerLayer.getMarker(marker.id) if properties? @overridePropertiesByMarker.set(marker, properties) else - @overridePropertiesByMarker.delete(marker.id) + @overridePropertiesByMarker.delete(marker) @decorationManager.emitDidUpdateDecorations() getPropertiesForMarker: (marker) -> From 927648d318b17d6aa0e163be289d59968805df73 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 17 Mar 2017 21:31:34 -0600 Subject: [PATCH 145/403] Use marker id as highlight key This keeps highlight elements in stable positions on the DOM, which ensures that CSS transitions don't appear in the wrong spot. --- src/text-editor-component.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 52688ab46bc..5e75a09eed1 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -528,7 +528,7 @@ class TextEditorComponent { this.addLineDecorationToRender(type, decoration, screenRange, reversed) break case 'highlight': - this.addHighlightDecorationToMeasure(decoration, screenRange) + this.addHighlightDecorationToMeasure(decoration, screenRange, marker.id) break case 'cursor': this.addCursorDecorationToMeasure(marker, screenRange, reversed) @@ -572,7 +572,7 @@ class TextEditorComponent { } } - addHighlightDecorationToMeasure(decoration, screenRange) { + addHighlightDecorationToMeasure(decoration, screenRange, key) { screenRange = constrainRangeToRows(screenRange, this.getRenderedStartRow(), this.getRenderedEndRow()) if (screenRange.isEmpty()) return @@ -594,7 +594,7 @@ class TextEditorComponent { tileHighlights.push({ screenRange: screenRangeInTile, - className, flashRequested, flashClass, flashDuration + key, className, flashRequested, flashClass, flashDuration }) this.requestHorizontalMeasurement(screenRangeInTile.start.row, screenRangeInTile.start.column) @@ -1800,7 +1800,7 @@ class LinesTileComponent { height: height + 'px', width: width + 'px' }, - }, children + }, children ) } From 2cde4aea76df59de6c429f3f614b85e8eca59c2f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sat, 18 Mar 2017 20:03:34 -0700 Subject: [PATCH 146/403] Remove TextEditorComponent.getModel --- src/text-editor-component.js | 60 ++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 5e75a09eed1..5c11102dba0 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -25,6 +25,8 @@ module.exports = class TextEditorComponent { constructor (props) { this.props = props + + if (!props.model) props.model = new TextEditor() if (props.element) { this.element = props.element } else { @@ -138,7 +140,7 @@ class TextEditorComponent { } render () { - const model = this.getModel() + const {model} = this.props const style = { overflow: 'hidden', @@ -240,7 +242,7 @@ class TextEditorComponent { } renderLineNumberGutter () { - const model = this.getModel() + const {model} = this.props if (!model.isLineNumberGutterVisible()) return null @@ -348,7 +350,7 @@ class TextEditorComponent { const tileHeight = this.measurements.lineHeight * rowsPerTile const tileWidth = this.getContentWidth() - const displayLayer = this.getModel().displayLayer + const displayLayer = this.props.model.displayLayer const tileNodes = new Array(this.getRenderedTileCount()) for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow += rowsPerTile) { @@ -484,7 +486,7 @@ class TextEditorComponent { } queryScreenLinesToRender () { - this.renderedScreenLines = this.getModel().displayLayer.getScreenLines( + this.renderedScreenLines = this.props.model.displayLayer.getScreenLines( this.getRenderedStartRow(), this.getRenderedEndRow() ) @@ -501,7 +503,7 @@ class TextEditorComponent { this.decorationsToMeasure.cursors.length = 0 const decorationsByMarker = - this.getModel().decorationManager.decorationPropertiesByMarkerForScreenRowRange( + this.props.model.decorationManager.decorationPropertiesByMarkerForScreenRowRange( this.getRenderedStartRow(), this.getRenderedEndRow() ) @@ -605,7 +607,7 @@ class TextEditorComponent { } addCursorDecorationToMeasure (marker, screenRange, reversed) { - const model = this.getModel() + const {model} = this.props const isLastCursor = model.getLastCursor().getMarker() === marker const screenPosition = reversed ? screenRange.start : screenRange.end const {row, column} = screenPosition @@ -693,7 +695,7 @@ class TextEditorComponent { if (!this.visible) { this.visible = true if (!this.measurements) this.performInitialMeasurements() - this.getModel().setVisible(true) + this.props.model.setVisible(true) this.updateSync() } } @@ -701,7 +703,7 @@ class TextEditorComponent { didHide () { if (this.visible) { this.visible = false - this.getModel().setVisible(false) + this.props.model.setVisible(false) } } @@ -789,17 +791,17 @@ class TextEditorComponent { // if (!this.isInputEnabled()) return if (this.compositionCheckpoint) { - this.getModel().revertToCheckpoint(this.compositionCheckpoint) + this.props.model.revertToCheckpoint(this.compositionCheckpoint) this.compositionCheckpoint = null } // Undo insertion of the original non-accented character so it is discarded // from the history and does not reappear on undo if (this.accentedCharacterMenuIsOpen) { - this.getModel().undo() + this.props.model.undo() } - this.getModel().insertText(event.data, {groupUndo: true}) + this.props.model.insertText(event.data, {groupUndo: true}) } // We need to get clever to detect when the accented character menu is @@ -822,7 +824,7 @@ class TextEditorComponent { if (this.lastKeydownBeforeKeypress != null) { if (this.lastKeydownBeforeKeypress.keyCode === event.keyCode) { this.accentedCharacterMenuIsOpen = true - this.getModel().selectLeft() + this.props.model.selectLeft() } this.lastKeydownBeforeKeypress = null } else { @@ -857,11 +859,11 @@ class TextEditorComponent { // 4. compositionend fired // 5. textInput fired; event.data == the completion string didCompositionStart () { - this.compositionCheckpoint = this.getModel().createCheckpoint() + this.compositionCheckpoint = this.props.model.createCheckpoint() } didCompositionUpdate (event) { - this.getModel().insertText(event.data, {select: true}) + this.props.model.insertText(event.data, {select: true}) } didCompositionEnd (event) { @@ -1188,7 +1190,7 @@ class TextEditorComponent { getVerticalScrollMargin () { const {clientHeight, lineHeight} = this.measurements const marginInLines = Math.min( - this.getModel().verticalScrollMargin, + this.props.model.verticalScrollMargin, Math.floor(((clientHeight / lineHeight) - 1) / 2) ) return marginInLines * lineHeight @@ -1198,7 +1200,7 @@ class TextEditorComponent { const {clientWidth, baseCharacterWidth} = this.measurements const contentClientWidth = clientWidth - this.getGutterContainerWidth() const marginInBaseCharacters = Math.min( - this.getModel().horizontalScrollMargin, + this.props.model.horizontalScrollMargin, Math.floor(((contentClientWidth / baseCharacterWidth) - 1) / 2) ) return marginInBaseCharacters * baseCharacterWidth @@ -1263,7 +1265,7 @@ class TextEditorComponent { } if (clientWidth !== this.measurements.clientWidth) { this.measurements.clientWidth = clientWidth - this.getModel().setWidth(clientWidth - this.getGutterContainerWidth(), true) + this.props.model.setWidth(clientWidth - this.getGutterContainerWidth(), true) } } @@ -1274,7 +1276,7 @@ class TextEditorComponent { this.measurements.halfWidthCharacterWidth = this.refs.halfWidthCharacterSpan.getBoundingClientRect().width this.measurements.koreanCharacterWidth = this.refs.koreanCharacterSpan.getBoundingClientRect().widt - this.getModel().setDefaultCharWidth( + this.props.model.setDefaultCharWidth( this.measurements.baseCharacterWidth, this.measurements.doubleWidthCharacterWidth, this.measurements.halfWidthCharacterWidth, @@ -1283,7 +1285,7 @@ class TextEditorComponent { } checkForNewLongestLine () { - const model = this.getModel() + const {model} = this.props const longestLineRow = model.getApproximateLongestScreenRow() const longestLine = model.screenLineForScreenRow(longestLineRow) if (longestLine !== this.previousLongestLine) { @@ -1391,7 +1393,7 @@ class TextEditorComponent { } screenPositionForPixelPosition({top, left}) { - const model = this.getModel() + const {model} = this.props const row = Math.min( Math.max(0, Math.floor(top / this.measurements.lineHeight)), @@ -1462,14 +1464,6 @@ class TextEditorComponent { return Point(row, column) } - getModel () { - if (!this.props.model) { - this.props.model = new TextEditor() - this.observeModel() - } - return this.props.model - } - observeModel () { const {model} = this.props model.component = this @@ -1511,7 +1505,7 @@ class TextEditorComponent { } getScrollHeight () { - const model = this.getModel() + const {model} = this.props const contentHeight = model.getApproximateScreenLineCount() * this.measurements.lineHeight if (model.getScrollPastEnd()) { const extraScrollHeight = Math.max( @@ -1541,7 +1535,7 @@ class TextEditorComponent { } getContentWidth () { - if (this.getModel().isSoftWrapped()) { + if (this.props.model.isSoftWrapped()) { return this.getClientWidth() - this.getGutterContainerWidth() } else { return Math.max( @@ -1573,7 +1567,7 @@ class TextEditorComponent { getRenderedEndRow () { return Math.min( - this.getModel().getApproximateScreenLineCount(), + this.props.model.getApproximateScreenLineCount(), this.getFirstTileStartRow() + this.getVisibleTileCount() * this.getRowsPerTile() ) } @@ -1595,7 +1589,7 @@ class TextEditorComponent { getLastVisibleRow () { const {scrollerHeight, lineHeight} = this.measurements return Math.min( - this.getModel().getApproximateScreenLineCount() - 1, + this.props.model.getApproximateScreenLineCount() - 1, this.getFirstVisibleRow() + Math.ceil(scrollerHeight / lineHeight) ) } @@ -1608,7 +1602,7 @@ class TextEditorComponent { // visible so we *at least* get the longest row in the visible range. populateVisibleRowRange () { const endRow = this.getFirstTileStartRow() + this.getVisibleTileCount() * this.getRowsPerTile() - this.getModel().displayLayer.populateSpatialIndexIfNeeded(Infinity, endRow) + this.props.model.displayLayer.populateSpatialIndexIfNeeded(Infinity, endRow) } topPixelPositionForRow (row) { From f58a249be1b7064cd11eb2373498879c6f4070b9 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sat, 18 Mar 2017 20:03:57 -0700 Subject: [PATCH 147/403] Pull from compnoent's rendered screen lines in tests --- spec/text-editor-component-spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 9967901ceea..4eecdac61bd 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1534,8 +1534,8 @@ function lineNumberNodeForScreenRow (component, row) { } function lineNodeForScreenRow (component, row) { - const screenLine = component.getModel().screenLineForScreenRow(row) - return component.lineNodesByScreenLineId.get(screenLine.id) + const renderedScreenLine = component.renderedScreenLineForRow(row) + return component.lineNodesByScreenLineId.get(renderedScreenLine.id) } function textNodesForScreenRow (component, row) { From e6026a145cf1cc5fc4e2d4e08b6bc0cd7cc6a8f4 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sat, 18 Mar 2017 20:08:08 -0700 Subject: [PATCH 148/403] Fix auto-width --- src/text-editor-component.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 5c11102dba0..3b09ba7d144 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1537,6 +1537,8 @@ class TextEditorComponent { getContentWidth () { if (this.props.model.isSoftWrapped()) { return this.getClientWidth() - this.getGutterContainerWidth() + } else if (this.props.model.getAutoWidth()) { + return Math.round(this.measurements.longestLineWidth + this.measurements.baseCharacterWidth) } else { return Math.max( Math.round(this.measurements.longestLineWidth + this.measurements.baseCharacterWidth), From 0e747a400d8df09deec52263d079158d503c8f21 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sat, 18 Mar 2017 20:08:38 -0700 Subject: [PATCH 149/403] Pull from component's rendered lines in tests --- spec/text-editor-component-spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 4eecdac61bd..6bc8cb363ec 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1539,7 +1539,7 @@ function lineNodeForScreenRow (component, row) { } function textNodesForScreenRow (component, row) { - const screenLine = component.getModel().screenLineForScreenRow(row) + const screenLine = component.renderedScreenLineForRow(row) return component.textNodesByScreenLineId.get(screenLine.id) } From 5a47f179e3cad7d68c2bf323372d035cc985c6e6 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sat, 18 Mar 2017 22:53:26 -0700 Subject: [PATCH 150/403] Introduce synthetic scrolling We previously thought scroll events had changed somehow to become synchronous, but were wrong. This introduces synthetic scrolling where we use GPU translation of the contents of the gutter and scroll containers to simulate scrolling and explicitly capture mousewheel events. Still need to add dummy scrollbars and deal with their footprint in clientHeight and clientWidth. --- spec/text-editor-component-spec.js | 206 +++++------- src/text-editor-component.js | 516 ++++++++++++++--------------- src/text-editor-element.js | 2 +- 3 files changed, 349 insertions(+), 375 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 6bc8cb363ec..e96d5ecd937 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -37,7 +37,7 @@ describe('TextEditorComponent', () => { expect(element.querySelectorAll('.line-number').length).toBe(9) expect(element.querySelectorAll('.line').length).toBe(9) - component.refs.scroller.scrollTop = 5 * component.measurements.lineHeight + component.setScrollTop(5 * component.getLineHeight()) await component.getNextUpdatePromise() // After scrolling down beyond > 3 rows, the order of line numbers and lines @@ -58,7 +58,7 @@ describe('TextEditorComponent', () => { editor.lineTextForScreenRow(8) ]) - component.refs.scroller.scrollTop = 2.5 * component.measurements.lineHeight + component.setScrollTop(2.5 * component.getLineHeight()) await component.getNextUpdatePromise() expect(Array.from(element.querySelectorAll('.line-number')).map(element => element.textContent.trim())).toEqual([ '1', '2', '3', '4', '5', '6', '7', '8', '9' @@ -88,25 +88,24 @@ describe('TextEditorComponent', () => { it('honors the scrollPastEnd option by adding empty space equivalent to the clientHeight to the end of the content area', async () => { const {component, element, editor} = buildComponent({autoHeight: false, autoWidth: false}) - const {scroller} = component.refs + const {scrollContainer} = component.refs await editor.update({scrollPastEnd: true}) await setEditorHeightInLines(component, 6) // scroll to end - scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight + component.setScrollTop(scrollContainer.scrollHeight - scrollContainer.clientHeight) await component.getNextUpdatePromise() expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() - 3) editor.update({scrollPastEnd: false}) await component.getNextUpdatePromise() // wait for scrollable content resize - await component.getNextUpdatePromise() // wait for async scroll event due to scrollbar shrinking expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() - 6) // Always allows at least 3 lines worth of overscroll if the editor is short await setEditorHeightInLines(component, 2) await editor.update({scrollPastEnd: true}) - scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight + component.setScrollTop(scrollContainer.scrollHeight - scrollContainer.clientHeight) await component.getNextUpdatePromise() expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() + 1) }) @@ -125,18 +124,9 @@ describe('TextEditorComponent', () => { expect(gutterElement.firstChild.style.contain).toBe('strict') }) - it('translates the gutter so it is always visible when scrolling to the right', async () => { - const {component, element, editor} = buildComponent({width: 100}) - - expect(component.refs.gutterContainer.style.transform).toBe('translateX(0px)') - component.refs.scroller.scrollLeft = 100 - await component.getNextUpdatePromise() - expect(component.refs.gutterContainer.style.transform).toBe('translateX(100px)') - }) - it('renders cursors within the visible row range', async () => { const {component, element, editor} = buildComponent({height: 40, rowsPerTile: 2}) - component.refs.scroller.scrollTop = 100 + component.setScrollTop(100) await component.getNextUpdatePromise() expect(component.getRenderedStartRow()).toBe(4) @@ -180,8 +170,8 @@ describe('TextEditorComponent', () => { it('places the hidden input element at the location of the last cursor if it is visible', async () => { const {component, element, editor} = buildComponent({height: 60, width: 120, rowsPerTile: 2}) const {hiddenInput} = component.refs - component.refs.scroller.scrollTop = 100 - component.refs.scroller.scrollLeft = 40 + component.setScrollTop(100) + component.setScrollLeft(40) await component.getNextUpdatePromise() expect(component.getRenderedStartRow()).toBe(4) @@ -205,6 +195,7 @@ describe('TextEditorComponent', () => { jasmine.attachToDOM(element) expect(getBaseCharacterWidth(component)).toBe(55) + console.log('running expectation'); expect(lineNodeForScreenRow(component, 3).textContent).toBe( ' var pivot = items.shift(), current, left = [], ' ) @@ -220,8 +211,8 @@ describe('TextEditorComponent', () => { ' = [], right = [];' ) - const {scroller} = component.refs - expect(scroller.clientWidth).toBe(scroller.scrollWidth) + const {scrollContainer} = component.refs + expect(scrollContainer.clientWidth).toBe(scrollContainer.scrollWidth) }) it('decorates the line numbers of folded lines', async () => { @@ -231,29 +222,30 @@ describe('TextEditorComponent', () => { expect(lineNumberNodeForScreenRow(component, 1).classList.contains('folded')).toBe(true) }) - it('makes lines at least as wide as the scroller', async () => { + it('makes lines at least as wide as the scrollContainer', async () => { const {component, element, editor} = buildComponent() - const {scroller, gutterContainer} = component.refs + const {scrollContainer, gutterContainer} = component.refs editor.setText('a') await component.getNextUpdatePromise() - expect(element.querySelector('.line').offsetWidth).toBe(scroller.offsetWidth - gutterContainer.offsetWidth) + expect(element.querySelector('.line').offsetWidth).toBe(scrollContainer.offsetWidth) }) it('resizes based on the content when the autoHeight and/or autoWidth options are true', async () => { const {component, element, editor} = buildComponent({autoHeight: true, autoWidth: true}) + const {gutterContainer, scrollContainer} = component.refs const initialWidth = element.offsetWidth const initialHeight = element.offsetHeight - expect(initialWidth).toBe(component.refs.scroller.scrollWidth) - expect(initialHeight).toBe(component.refs.scroller.scrollHeight) + expect(initialWidth).toBe(gutterContainer.offsetWidth + scrollContainer.scrollWidth) + expect(initialHeight).toBe(scrollContainer.scrollHeight) editor.setCursorScreenPosition([6, Infinity]) editor.insertText('x'.repeat(50)) await component.getNextUpdatePromise() - expect(element.offsetWidth).toBe(component.refs.scroller.scrollWidth) + expect(element.offsetWidth).toBe(gutterContainer.offsetWidth + scrollContainer.scrollWidth) expect(element.offsetWidth).toBeGreaterThan(initialWidth) editor.insertText('\n'.repeat(5)) await component.getNextUpdatePromise() - expect(element.offsetHeight).toBe(component.refs.scroller.scrollHeight) + expect(element.offsetHeight).toBe(scrollContainer.scrollHeight) expect(element.offsetHeight).toBeGreaterThan(initialHeight) }) @@ -369,32 +361,29 @@ describe('TextEditorComponent', () => { describe('autoscroll on cursor movement', () => { it('automatically scrolls vertically when the requested range is within the vertical scroll margin of the top or bottom', async () => { - const {component, element, editor} = buildComponent({height: 120}) - const {scroller} = component.refs + const {component, editor} = buildComponent({height: 120}) expect(component.getLastVisibleRow()).toBe(8) editor.scrollToScreenRange([[4, 0], [6, 0]]) await component.getNextUpdatePromise() - let scrollBottom = scroller.scrollTop + scroller.clientHeight - expect(scrollBottom).toBe((6 + 1 + editor.verticalScrollMargin) * component.measurements.lineHeight) + expect(component.getScrollBottom()).toBe((6 + 1 + editor.verticalScrollMargin) * component.getLineHeight()) editor.scrollToScreenPosition([8, 0]) await component.getNextUpdatePromise() - scrollBottom = scroller.scrollTop + scroller.clientHeight - expect(scrollBottom).toBe((8 + 1 + editor.verticalScrollMargin) * component.measurements.lineHeight) + expect(component.getScrollBottom()).toBe((8 + 1 + editor.verticalScrollMargin) * component.measurements.lineHeight) editor.scrollToScreenPosition([3, 0]) await component.getNextUpdatePromise() - expect(scroller.scrollTop).toBe((3 - editor.verticalScrollMargin) * component.measurements.lineHeight) + expect(component.getScrollTop()).toBe((3 - editor.verticalScrollMargin) * component.measurements.lineHeight) editor.scrollToScreenPosition([2, 0]) await component.getNextUpdatePromise() - expect(scroller.scrollTop).toBe(0) + expect(component.getScrollTop()).toBe(0) }) it('does not vertically autoscroll by more than half of the visible lines if the editor is shorter than twice the scroll margin', async () => { const {component, element, editor} = buildComponent({autoHeight: false}) - const {scroller} = component.refs + const {scrollContainer} = component.refs element.style.height = 5.5 * component.measurements.lineHeight + 'px' await component.getNextUpdatePromise() expect(component.getLastVisibleRow()).toBe(6) @@ -402,26 +391,24 @@ describe('TextEditorComponent', () => { editor.scrollToScreenPosition([6, 0]) await component.getNextUpdatePromise() - let scrollBottom = scroller.scrollTop + scroller.clientHeight - expect(scrollBottom).toBe((6 + 1 + scrollMarginInLines) * component.measurements.lineHeight) + expect(component.getScrollBottom()).toBe((6 + 1 + scrollMarginInLines) * component.measurements.lineHeight) editor.scrollToScreenPosition([6, 4]) await component.getNextUpdatePromise() - scrollBottom = scroller.scrollTop + scroller.clientHeight - expect(scrollBottom).toBe((6 + 1 + scrollMarginInLines) * component.measurements.lineHeight) + expect(component.getScrollBottom()).toBe((6 + 1 + scrollMarginInLines) * component.measurements.lineHeight) editor.scrollToScreenRange([[4, 4], [6, 4]]) await component.getNextUpdatePromise() - expect(scroller.scrollTop).toBe((4 - scrollMarginInLines) * component.measurements.lineHeight) + expect(component.getScrollTop()).toBe((4 - scrollMarginInLines) * component.measurements.lineHeight) editor.scrollToScreenRange([[4, 4], [6, 4]], {reversed: false}) await component.getNextUpdatePromise() - expect(scrollBottom).toBe((6 + 1 + scrollMarginInLines) * component.measurements.lineHeight) + expect(component.getScrollBottom()).toBe((6 + 1 + scrollMarginInLines) * component.measurements.lineHeight) }) - it('automatically scrolls horizontally when the requested range is within the horizontal scroll margin of the right edge of the gutter or right edge of the screen', async () => { + it('automatically scrolls horizontally when the requested range is within the horizontal scroll margin of the right edge of the gutter or right edge of the scroll container', async () => { const {component, element, editor} = buildComponent() - const {scroller} = component.refs + const {scrollContainer} = component.refs element.style.width = component.getGutterContainerWidth() + 3 * editor.horizontalScrollMargin * component.measurements.baseCharacterWidth + 'px' @@ -429,32 +416,30 @@ describe('TextEditorComponent', () => { editor.scrollToScreenRange([[1, 12], [2, 28]]) await component.getNextUpdatePromise() - let expectedScrollLeft = Math.floor( + let expectedScrollLeft = Math.round( clientLeftForCharacter(component, 1, 12) - lineNodeForScreenRow(component, 1).getBoundingClientRect().left - (editor.horizontalScrollMargin * component.measurements.baseCharacterWidth) ) - expect(scroller.scrollLeft).toBe(expectedScrollLeft) + expect(component.getScrollLeft()).toBe(expectedScrollLeft) editor.scrollToScreenRange([[1, 12], [2, 28]], {reversed: false}) await component.getNextUpdatePromise() - expectedScrollLeft = Math.floor( + expectedScrollLeft = Math.round( component.getGutterContainerWidth() + clientLeftForCharacter(component, 2, 28) - lineNodeForScreenRow(component, 2).getBoundingClientRect().left + (editor.horizontalScrollMargin * component.measurements.baseCharacterWidth) - - scroller.clientWidth + scrollContainer.clientWidth ) - expect(scroller.scrollLeft).toBe(expectedScrollLeft) + expect(component.getScrollLeft()).toBe(expectedScrollLeft) }) it('does not horizontally autoscroll by more than half of the visible "base-width" characters if the editor is narrower than twice the scroll margin', async () => { - const {component, element, editor} = buildComponent({autoHeight: false}) - const {scroller, gutterContainer} = component.refs + const {component, editor} = buildComponent({autoHeight: false}) await setEditorWidthInCharacters(component, 1.5 * editor.horizontalScrollMargin) - const contentWidth = scroller.clientWidth - gutterContainer.offsetWidth - const contentWidthInCharacters = Math.floor(contentWidth / component.measurements.baseCharacterWidth) + const contentWidthInCharacters = Math.floor(component.getScrollContainerClientWidth() / component.getBaseCharacterWidth()) expect(contentWidthInCharacters).toBe(9) editor.scrollToScreenRange([[6, 10], [6, 15]]) @@ -462,27 +447,26 @@ describe('TextEditorComponent', () => { let expectedScrollLeft = Math.floor( clientLeftForCharacter(component, 6, 10) - lineNodeForScreenRow(component, 1).getBoundingClientRect().left - - (4 * component.measurements.baseCharacterWidth) + (4 * component.getBaseCharacterWidth()) ) - expect(scroller.scrollLeft).toBe(expectedScrollLeft) + expect(component.getScrollLeft()).toBe(expectedScrollLeft) }) it('correctly autoscrolls after inserting a line that exceeds the current content width', async () => { const {component, element, editor} = buildComponent() - const {scroller} = component.refs - element.style.width = component.getGutterContainerWidth() + component.measurements.longestLineWidth + 'px' + element.style.width = component.getGutterContainerWidth() + component.getContentWidth() + 'px' await component.getNextUpdatePromise() editor.setCursorScreenPosition([0, Infinity]) editor.insertText('x'.repeat(100)) await component.getNextUpdatePromise() - expect(scroller.scrollLeft).toBe(component.getScrollWidth() - scroller.clientWidth) + expect(component.getScrollLeft()).toBe(component.getScrollWidth() - component.getScrollContainerClientWidth()) }) it('accounts for the presence of horizontal scrollbars that appear during the same frame as the autoscroll', async () => { const {component, element, editor} = buildComponent() - const {scroller} = component.refs + const {scrollContainer} = component.refs element.style.height = component.getScrollHeight() + 'px' element.style.width = component.getScrollWidth() + 'px' await component.getNextUpdatePromise() @@ -491,8 +475,8 @@ describe('TextEditorComponent', () => { editor.insertText('\n\n' + 'x'.repeat(100)) await component.getNextUpdatePromise() - expect(scroller.scrollTop).toBe(component.getScrollHeight() - scroller.clientHeight) - expect(scroller.scrollLeft).toBe(component.getScrollWidth() - scroller.clientWidth) + expect(component.getScrollTop()).toBe(component.getScrollHeight() - component.getScrollContainerClientHeight()) + expect(component.getScrollLeft()).toBe(component.getScrollWidth() - component.getScrollContainerClientWidth()) }) }) @@ -735,7 +719,7 @@ describe('TextEditorComponent', () => { ) // Don't flash on next update if another flash wasn't requested - component.refs.scroller.scrollTop = 100 + component.setScrollTop(100) await component.getNextUpdatePromise() expect(highlights[0].classList.contains('b')).toBe(false) expect(highlights[1].classList.contains('b')).toBe(false) @@ -1156,29 +1140,29 @@ describe('TextEditorComponent', () => { expect(editor.getCursorScreenPosition()).toEqual([0, 0]) }) - it('autoscrolls the content when dragging near the edge of the screen', async () => { - const {component, editor} = buildComponent({width: 200, height: 200}) - const {scroller} = component.refs + it('autoscrolls the content when dragging near the edge of the scroll container', async () => { + const {component, element, editor} = buildComponent({width: 200, height: 200}) spyOn(component, 'handleMouseDragUntilMouseUp') let previousScrollTop = 0 let previousScrollLeft = 0 function assertScrolledDownAndRight () { - expect(scroller.scrollTop).toBeGreaterThan(previousScrollTop) - previousScrollTop = scroller.scrollTop - expect(scroller.scrollLeft).toBeGreaterThan(previousScrollLeft) - previousScrollLeft = scroller.scrollLeft + expect(component.getScrollTop()).toBeGreaterThan(previousScrollTop) + previousScrollTop = component.getScrollTop() + expect(component.getScrollLeft()).toBeGreaterThan(previousScrollLeft) + previousScrollLeft = component.getScrollLeft() } function assertScrolledUpAndLeft () { - expect(scroller.scrollTop).toBeLessThan(previousScrollTop) - previousScrollTop = scroller.scrollTop - expect(scroller.scrollLeft).toBeLessThan(previousScrollLeft) - previousScrollLeft = scroller.scrollLeft + expect(component.getScrollTop()).toBeLessThan(previousScrollTop) + previousScrollTop = component.getScrollTop() + expect(component.getScrollLeft()).toBeLessThan(previousScrollLeft) + previousScrollLeft = component.getScrollLeft() } component.didMouseDownOnContent({detail: 1, button: 0, clientX: 100, clientY: 100}) const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[0][0] + didDrag({clientX: 199, clientY: 199}) assertScrolledDownAndRight() didDrag({clientX: 199, clientY: 199}) @@ -1192,27 +1176,24 @@ describe('TextEditorComponent', () => { didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) assertScrolledUpAndLeft() - // Don't artificially update scroll measurements beyond the minimum or - // maximum possible scroll positions - expect(scroller.scrollTop).toBe(0) - expect(scroller.scrollLeft).toBe(0) + // Don't artificially update scroll position beyond possible values + expect(component.getScrollTop()).toBe(0) + expect(component.getScrollLeft()).toBe(0) didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) - expect(component.measurements.scrollTop).toBe(0) - expect(scroller.scrollTop).toBe(0) - expect(component.measurements.scrollLeft).toBe(0) - expect(scroller.scrollLeft).toBe(0) - - const maxScrollTop = scroller.scrollHeight - scroller.clientHeight - const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth - scroller.scrollTop = maxScrollTop - scroller.scrollLeft = maxScrollLeft + expect(component.getScrollTop()).toBe(0) + expect(component.getScrollLeft()).toBe(0) + + const maxScrollTop = component.getMaxScrollTop() + const maxScrollLeft = component.getMaxScrollLeft() + component.setScrollTop(maxScrollTop) + component.setScrollLeft(maxScrollLeft) await component.getNextUpdatePromise() didDrag({clientX: 199, clientY: 199}) didDrag({clientX: 199, clientY: 199}) didDrag({clientX: 199, clientY: 199}) - expect(component.measurements.scrollTop).toBe(maxScrollTop) - expect(component.measurements.scrollLeft).toBe(maxScrollLeft) + expect(component.getScrollTop()).toBe(maxScrollTop) + expect(component.getScrollLeft()).toBe(maxScrollLeft) }) }) @@ -1387,25 +1368,25 @@ describe('TextEditorComponent', () => { expect(editor.isFoldedAtScreenRow(1)).toBe(false) }) - it('autoscrolls the content when dragging near the edge of the screen', async () => { + it('autoscrolls when dragging near the top or bottom of the gutter', async () => { const {component, editor} = buildComponent({width: 200, height: 200}) - const {scroller} = component.refs + const {scrollContainer} = component.refs spyOn(component, 'handleMouseDragUntilMouseUp') let previousScrollTop = 0 let previousScrollLeft = 0 function assertScrolledDown () { - expect(scroller.scrollTop).toBeGreaterThan(previousScrollTop) - previousScrollTop = scroller.scrollTop - expect(scroller.scrollLeft).toBe(previousScrollLeft) - previousScrollLeft = scroller.scrollLeft + expect(component.getScrollTop()).toBeGreaterThan(previousScrollTop) + previousScrollTop = component.getScrollTop() + expect(component.getScrollLeft()).toBe(previousScrollLeft) + previousScrollLeft = component.getScrollLeft() } function assertScrolledUp () { - expect(scroller.scrollTop).toBeLessThan(previousScrollTop) - previousScrollTop = scroller.scrollTop - expect(scroller.scrollLeft).toBe(previousScrollLeft) - previousScrollLeft = scroller.scrollLeft + expect(component.getScrollTop()).toBeLessThan(previousScrollTop) + previousScrollTop = component.getScrollTop() + expect(component.getScrollLeft()).toBe(previousScrollLeft) + previousScrollLeft = component.getScrollLeft() } component.didMouseDownOnLineNumberGutter({detail: 1, button: 0, clientX: 0, clientY: 100}) @@ -1425,25 +1406,23 @@ describe('TextEditorComponent', () => { // Don't artificially update scroll measurements beyond the minimum or // maximum possible scroll positions - expect(scroller.scrollTop).toBe(0) - expect(scroller.scrollLeft).toBe(0) + expect(component.getScrollTop()).toBe(0) + expect(component.getScrollLeft()).toBe(0) didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) - expect(component.measurements.scrollTop).toBe(0) - expect(scroller.scrollTop).toBe(0) - expect(component.measurements.scrollLeft).toBe(0) - expect(scroller.scrollLeft).toBe(0) - - const maxScrollTop = scroller.scrollHeight - scroller.clientHeight - const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth - scroller.scrollTop = maxScrollTop - scroller.scrollLeft = maxScrollLeft + expect(component.getScrollTop()).toBe(0) + expect(component.getScrollLeft()).toBe(0) + + const maxScrollTop = component.getMaxScrollTop() + const maxScrollLeft = component.getMaxScrollLeft() + component.setScrollTop(maxScrollTop) + component.setScrollLeft(maxScrollLeft) await component.getNextUpdatePromise() didDrag({clientX: 199, clientY: 199}) didDrag({clientX: 199, clientY: 199}) didDrag({clientX: 199, clientY: 199}) - expect(component.measurements.scrollTop).toBe(maxScrollTop) - expect(component.measurements.scrollLeft).toBe(maxScrollLeft) + expect(component.getScrollTop()).toBe(maxScrollTop) + expect(component.getScrollLeft()).toBe(maxScrollLeft) }) }) }) @@ -1475,10 +1454,7 @@ function buildComponent (params = {}) { } function getBaseCharacterWidth (component) { - return Math.round( - (component.refs.scroller.clientWidth - component.getGutterContainerWidth()) / - component.measurements.baseCharacterWidth - ) + return Math.round(component.getScrollContainerWidth() / component.getBaseCharacterWidth()) } async function setEditorHeightInLines(component, heightInLines) { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 3b09ba7d144..1016dcb7cce 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -16,6 +16,7 @@ const KOREAN_CHARACTER = '세' const NBSP_CHARACTER = '\u00a0' const ZERO_WIDTH_NBSP_CHARACTER = '\ufeff' const MOUSE_DRAG_AUTOSCROLL_MARGIN = 40 +const MOUSE_WHEEL_SCROLL_SENSITIVITY = 0.8 function scaleMouseDragAutoscrollDelta (delta) { return Math.pow(delta / 3, 3) / 280 @@ -47,7 +48,8 @@ class TextEditorComponent { this.lineNodesByScreenLineId = new Map() this.textNodesByScreenLineId = new Map() this.pendingAutoscroll = null - this.autoscrollTop = null + this.scrollTop = 0 + this.scrollLeft = 0 this.previousScrollWidth = 0 this.previousScrollHeight = 0 this.lastKeydown = null @@ -97,7 +99,7 @@ class TextEditorComponent { } this.horizontalPositionsToMeasure.clear() - if (this.pendingAutoscroll) this.initiateAutoscroll() + if (this.pendingAutoscroll) this.autoscrollVertically() this.populateVisibleRowRange() const longestLineToMeasure = this.checkForNewLongestLine() this.queryScreenLinesToRender() @@ -111,19 +113,10 @@ class TextEditorComponent { etch.updateSync(this) - // If scrollHeight or scrollWidth changed, we may have shown or hidden - // scrollbars, affecting the clientWidth or clientHeight - if (this.checkIfScrollDimensionsChanged()) { - this.measureClientDimensions() - // If the clientHeight changed, our previous vertical autoscroll may have - // been off by the height of the horizontal scrollbar. If we *still* need - // to autoscroll, just re-render the frame. - if (this.pendingAutoscroll && this.initiateAutoscroll()) { - this.updateSync() - return - } + if (this.pendingAutoscroll) { + this.autoscrollHorizontally() + this.pendingAutoscroll = null } - if (this.pendingAutoscroll) this.finalizeAutoscroll() this.currentFrameLineNumberGutterProps = null } @@ -142,103 +135,82 @@ class TextEditorComponent { render () { const {model} = this.props - const style = { - overflow: 'hidden', - } + const style = {} if (!model.getAutoHeight() && !model.getAutoWidth()) { style.contain = 'strict' } - if (this.measurements) { if (model.getAutoHeight()) { - style.height = this.getScrollHeight() + 'px' + style.height = this.getContentHeight() + 'px' } if (model.getAutoWidth()) { - style.width = this.getScrollWidth() + 'px' + style.width = this.getGutterContainerWidth() + this.getContentWidth() + 'px' } } let attributes = null let className = 'editor' - if (this.focused) { - className += ' is-focused' - } + if (this.focused) className += ' is-focused' if (model.isMini()) { attributes = {mini: ''} className += ' mini' } - const scrollerOverflowX = (model.isMini() || model.isSoftWrapped()) ? 'hidden' : 'auto' - const scrollerOverflowY = model.isMini() ? 'hidden' : 'auto' - return $('atom-text-editor', { className, - attributes, style, + attributes, tabIndex: -1, on: { focus: this.didFocus, - blur: this.didBlur + blur: this.didBlur, + mousewheel: this.didMouseWheel } }, $.div( { + ref: 'clientContainer', style: { position: 'relative', + contain: 'strict', + overflow: 'hidden', + backgroundColor: 'inherit', width: '100%', - height: '100%', - backgroundColor: 'inherit' + height: '100%' } }, - $.div( - { - ref: 'scroller', - className: 'scroll-view', - on: {scroll: this.didScroll}, - style: { - position: 'absolute', - contain: 'strict', - top: 0, - right: 0, - bottom: 0, - left: 0, - overflowX: scrollerOverflowX, - overflowY: scrollerOverflowY, - backgroundColor: 'inherit' - } - }, - $.div( - { - style: { - isolate: 'content', - width: 'max-content', - height: 'max-content', - backgroundColor: 'inherit' - } - }, - this.renderGutterContainer(), - this.renderContent() - ) - ) + this.renderGutterContainer(), + this.renderScrollContainer() ) ) } renderGutterContainer () { if (this.props.model.isMini()) return null - const props = {ref: 'gutterContainer', className: 'gutter-container'} + const innerStyle = { + willChange: 'transform', + backgroundColor: 'inherit' + } if (this.measurements) { - props.style = { - position: 'relative', - willChange: 'transform', - transform: `translateX(${this.measurements.scrollLeft}px)`, - zIndex: 1 - } + innerStyle.transform = `translateY(${-this.getScrollTop()}px)` } - return $.div(props, this.renderLineNumberGutter()) + return $.div( + { + ref: 'gutterContainer', + className: 'gutter-container', + style: { + position: 'relative', + zIndex: 1, + backgroundColor: 'inherit' + } + }, + $.div({style: innerStyle}, + this.renderLineNumberGutter() + ) + ) } renderLineNumberGutter () { @@ -278,8 +250,8 @@ class TextEditorComponent { ref: 'lineNumberGutter', parentComponent: this, height: this.getScrollHeight(), - width: this.measurements.lineNumberGutterWidth, - lineHeight: this.measurements.lineHeight, + width: this.getLineNumberGutterWidth(), + lineHeight: this.getLineHeight(), startRow, endRow, rowsPerTile, maxLineNumberDigits, bufferRows, lineNumberDecorations, softWrappedFlags, foldableFlags @@ -301,6 +273,31 @@ class TextEditorComponent { } } + renderScrollContainer () { + const style = { + position: 'absolute', + contain: 'strict', + overflow: 'hidden', + top: 0, + bottom: 0, + backgroundColor: 'inherit' + } + + if (this.measurements) { + style.left = this.getGutterContainerWidth() + 'px' + style.width = this.getScrollContainerWidth() + 'px' + } + + return $.div( + { + ref: 'scrollContainer', + className: 'scroll-view', + style + }, + this.renderContent() + ) + } + renderContent () { let children let style = { @@ -309,15 +306,13 @@ class TextEditorComponent { backgroundColor: 'inherit' } if (this.measurements) { - const contentWidth = this.getContentWidth() - const scrollHeight = this.getScrollHeight() - const width = contentWidth + 'px' - const height = scrollHeight + 'px' - style.width = width - style.height = height + style.width = this.getScrollWidth() + 'px' + style.height = this.getScrollHeight() + 'px' + style.willChange = 'transform' + style.transform = `translate(${-this.getScrollLeft()}px, ${-this.getScrollTop()}px)` children = [ - this.renderCursorsAndInput(width, height), - this.renderLineTiles(width, height), + this.renderCursorsAndInput(), + this.renderLineTiles(), this.renderPlaceholderText() ] } else { @@ -339,7 +334,7 @@ class TextEditorComponent { ) } - renderLineTiles (width, height) { + renderLineTiles () { if (!this.measurements) return [] const {lineNodesByScreenLineId, textNodesByScreenLineId} = this @@ -347,8 +342,8 @@ class TextEditorComponent { const startRow = this.getRenderedStartRow() const endRow = this.getRenderedEndRow() const rowsPerTile = this.getRowsPerTile() - const tileHeight = this.measurements.lineHeight * rowsPerTile - const tileWidth = this.getContentWidth() + const tileHeight = this.getLineHeight() * rowsPerTile + const tileWidth = this.getScrollWidth() const displayLayer = this.props.model.displayLayer const tileNodes = new Array(this.getRenderedTileCount()) @@ -368,7 +363,7 @@ class TextEditorComponent { height: tileHeight, width: tileWidth, top: this.topPixelPositionForRow(tileStartRow), - lineHeight: this.measurements.lineHeight, + lineHeight: this.getLineHeight(), screenLines: this.renderedScreenLines.slice(tileStartRow - startRow, tileEndRow - startRow), lineDecorations, highlightDecorations, @@ -395,14 +390,16 @@ class TextEditorComponent { style: { position: 'absolute', contain: 'strict', - width, height, + overflow: 'hidden', + width: this.getScrollWidth() + 'px', + height: this.getScrollHeight() + 'px', backgroundColor: 'inherit' } }, tileNodes) } - renderCursorsAndInput (width, height) { - const cursorHeight = this.measurements.lineHeight + 'px' + renderCursorsAndInput () { + const cursorHeight = this.getLineHeight() + 'px' const children = [this.renderHiddenInput()] @@ -425,7 +422,8 @@ class TextEditorComponent { position: 'absolute', contain: 'strict', zIndex: 1, - width, height + width: this.getScrollWidth() + 'px', + height: this.getScrollHeight() + 'px' } }, children) } @@ -470,7 +468,7 @@ class TextEditorComponent { style: { position: 'absolute', width: '1px', - height: this.measurements.lineHeight + 'px', + height: this.getLineHeight() + 'px', top: top + 'px', left: left + 'px', opacity: 0, @@ -646,7 +644,7 @@ class TextEditorComponent { updateCursorsToRender () { this.decorationsToRender.cursors.length = 0 - const height = this.measurements.lineHeight + 'px' + const height = this.getLineHeight() + 'px' for (let i = 0; i < this.decorationsToMeasure.cursors.length; i++) { const cursor = this.decorationsToMeasure.cursors[i] const {row, column} = cursor.screenPosition @@ -765,15 +763,20 @@ class TextEditorComponent { } } - didScroll () { - if (this.measureScrollPosition(true)) { - this.updateSync() - } + didMouseWheel (eveWt) { + let {deltaX, deltaY} = event + deltaX = deltaX * MOUSE_WHEEL_SCROLL_SENSITIVITY + deltaY = deltaY * MOUSE_WHEEL_SCROLL_SENSITIVITY + + const scrollPositionChanged = + this.setScrollLeft(this.getScrollLeft() + deltaX) || + this.setScrollTop(this.getScrollTop() + deltaY) + + if (scrollPositionChanged) this.updateSync() } didResize () { - if (this.measureEditorDimensions()) { - this.measureClientDimensions() + if (this.measureClientContainerDimensions()) { this.scheduleUpdate() } } @@ -1023,10 +1026,10 @@ class TextEditorComponent { } autoscrollOnMouseDrag ({clientX, clientY}, verticalOnly = false) { - let {top, bottom, left, right} = this.refs.scroller.getBoundingClientRect() + let {top, bottom, left, right} = this.refs.scrollContainer.getBoundingClientRect() top += MOUSE_DRAG_AUTOSCROLL_MARGIN bottom -= MOUSE_DRAG_AUTOSCROLL_MARGIN - left += this.getGutterContainerWidth() + MOUSE_DRAG_AUTOSCROLL_MARGIN + left += MOUSE_DRAG_AUTOSCROLL_MARGIN right -= MOUSE_DRAG_AUTOSCROLL_MARGIN let yDelta, yDirection @@ -1050,31 +1053,21 @@ class TextEditorComponent { let scrolled = false if (yDelta != null) { const scaledDelta = scaleMouseDragAutoscrollDelta(yDelta) * yDirection - const newScrollTop = this.constrainScrollTop(this.measurements.scrollTop + scaledDelta) - if (newScrollTop !== this.measurements.scrollTop) { - this.measurements.scrollTop = newScrollTop - this.refs.scroller.scrollTop = newScrollTop - scrolled = true - } + scrolled = this.setScrollTop(this.getScrollTop() + scaledDelta) } if (!verticalOnly && xDelta != null) { const scaledDelta = scaleMouseDragAutoscrollDelta(xDelta) * xDirection - const newScrollLeft = this.constrainScrollLeft(this.measurements.scrollLeft + scaledDelta) - if (newScrollLeft !== this.measurements.scrollLeft) { - this.measurements.scrollLeft = newScrollLeft - this.refs.scroller.scrollLeft = newScrollLeft - scrolled = true - } + scrolled = this.setScrollLeft(this.getScrollLeft() + scaledDelta) } if (scrolled) this.updateSync() } screenPositionForMouseEvent ({clientX, clientY}) { - const scrollerRect = this.refs.scroller.getBoundingClientRect() - clientX = Math.min(scrollerRect.right, Math.max(scrollerRect.left, clientX)) - clientY = Math.min(scrollerRect.bottom, Math.max(scrollerRect.top, clientY)) + const scrollContainerRect = this.refs.scrollContainer.getBoundingClientRect() + clientX = Math.min(scrollContainerRect.right, Math.max(scrollContainerRect.left, clientX)) + clientY = Math.min(scrollContainerRect.bottom, Math.max(scrollContainerRect.top, clientY)) const linesRect = this.refs.lineTiles.getBoundingClientRect() return this.screenPositionForPixelPosition({ top: clientY - linesRect.top, @@ -1087,12 +1080,12 @@ class TextEditorComponent { this.scheduleUpdate() } - initiateAutoscroll () { + autoscrollVertically () { const {screenRange, options} = this.pendingAutoscroll const screenRangeTop = this.pixelTopForRow(screenRange.start.row) - const screenRangeBottom = this.pixelTopForRow(screenRange.end.row) + this.measurements.lineHeight - const verticalScrollMargin = this.getVerticalScrollMargin() + const screenRangeBottom = this.pixelTopForRow(screenRange.end.row) + this.getLineHeight() + const verticalScrollMargin = this.getVerticalAutoscrollMargin() this.requestHorizontalMeasurement(screenRange.start.row, screenRange.start.column) this.requestHorizontalMeasurement(screenRange.end.row, screenRange.end.column) @@ -1109,43 +1102,27 @@ class TextEditorComponent { desiredScrollBottom = screenRangeBottom + verticalScrollMargin } - if (desiredScrollTop != null) { - desiredScrollTop = this.constrainScrollTop(desiredScrollTop) - } - - if (desiredScrollBottom != null) { - desiredScrollBottom = this.constrainScrollTop(desiredScrollBottom - this.getClientHeight()) + this.getClientHeight() - } - if (!options || options.reversed !== false) { if (desiredScrollBottom > this.getScrollBottom()) { - this.autoscrollTop = desiredScrollBottom - this.measurements.clientHeight - this.measurements.scrollTop = this.autoscrollTop - return true + return this.setScrollBottom(desiredScrollBottom, true) } if (desiredScrollTop < this.getScrollTop()) { - this.autoscrollTop = desiredScrollTop - this.measurements.scrollTop = this.autoscrollTop - return true + return this.setScrollTop(desiredScrollTop, true) } } else { if (desiredScrollTop < this.getScrollTop()) { - this.autoscrollTop = desiredScrollTop - this.measurements.scrollTop = this.autoscrollTop - return true + return this.setScrollTop(desiredScrollTop, true) } if (desiredScrollBottom > this.getScrollBottom()) { - this.autoscrollTop = desiredScrollBottom - this.measurements.clientHeight - this.measurements.scrollTop = this.autoscrollTop - return true + return this.setScrollBottom(desiredScrollBottom, true) } } return false } - finalizeAutoscroll () { - const horizontalScrollMargin = this.getHorizontalScrollMargin() + autoscrollHorizontally () { + const horizontalScrollMargin = this.getHorizontalAutoscrollMargin() const {screenRange, options} = this.pendingAutoscroll const gutterContainerWidth = this.getGutterContainerWidth() @@ -1154,121 +1131,70 @@ class TextEditorComponent { const desiredScrollLeft = Math.max(0, left - horizontalScrollMargin - gutterContainerWidth) const desiredScrollRight = Math.min(this.getScrollWidth(), right + horizontalScrollMargin) - let autoscrollLeft if (!options || options.reversed !== false) { if (desiredScrollRight > this.getScrollRight()) { - autoscrollLeft = desiredScrollRight - this.getClientWidth() - this.measurements.scrollLeft = autoscrollLeft + this.setScrollRight(desiredScrollRight, true) } if (desiredScrollLeft < this.getScrollLeft()) { - autoscrollLeft = desiredScrollLeft - this.measurements.scrollLeft = autoscrollLeft + this.setScrollLeft(desiredScrollLeft, true) } } else { if (desiredScrollLeft < this.getScrollLeft()) { - autoscrollLeft = desiredScrollLeft - this.measurements.scrollLeft = autoscrollLeft + this.setScrollLeft(desiredScrollLeft, true) } if (desiredScrollRight > this.getScrollRight()) { - autoscrollLeft = desiredScrollRight - this.getClientWidth() - this.measurements.scrollLeft = autoscrollLeft + this.setScrollRight(desiredScrollRight, true) } } - - if (this.autoscrollTop != null) { - this.refs.scroller.scrollTop = this.autoscrollTop - this.autoscrollTop = null - } - - if (autoscrollLeft != null) { - this.refs.scroller.scrollLeft = autoscrollLeft - } - - this.pendingAutoscroll = null } - getVerticalScrollMargin () { - const {clientHeight, lineHeight} = this.measurements + getVerticalAutoscrollMargin () { + const maxMarginInLines = Math.floor( + (this.getScrollContainerClientHeight() / this.getLineHeight() - 1) / 2 + ) const marginInLines = Math.min( this.props.model.verticalScrollMargin, - Math.floor(((clientHeight / lineHeight) - 1) / 2) + maxMarginInLines ) - return marginInLines * lineHeight + return marginInLines * this.getLineHeight() } - getHorizontalScrollMargin () { - const {clientWidth, baseCharacterWidth} = this.measurements - const contentClientWidth = clientWidth - this.getGutterContainerWidth() + getHorizontalAutoscrollMargin () { + const maxMarginInBaseCharacters = Math.floor( + (this.getScrollContainerClientWidth() / this.getBaseCharacterWidth() - 1) / 2 + ) const marginInBaseCharacters = Math.min( this.props.model.horizontalScrollMargin, - Math.floor(((contentClientWidth / baseCharacterWidth) - 1) / 2) - ) - return marginInBaseCharacters * baseCharacterWidth - } - - constrainScrollTop (desiredScrollTop) { - return Math.max( - 0, Math.min(desiredScrollTop, this.getScrollHeight() - this.getClientHeight()) - ) - } - - constrainScrollLeft (desiredScrollLeft) { - return Math.max( - 0, Math.min(desiredScrollLeft, this.getScrollWidth() - this.getClientWidth()) + maxMarginInBaseCharacters ) + return marginInBaseCharacters * this.getBaseCharacterWidth() } performInitialMeasurements () { this.measurements = {} - this.measureGutterDimensions() - this.measureEditorDimensions() - this.measureClientDimensions() - this.measureScrollPosition() this.measureCharacterDimensions() + this.measureGutterDimensions() + this.measureClientContainerDimensions() } - measureEditorDimensions () { + measureClientContainerDimensions () { if (!this.measurements) return false let dimensionsChanged = false - const scrollerHeight = this.refs.scroller.offsetHeight - const scrollerWidth = this.refs.scroller.offsetWidth - if (scrollerHeight !== this.measurements.scrollerHeight) { - this.measurements.scrollerHeight = scrollerHeight + const clientContainerHeight = this.refs.clientContainer.offsetHeight + const clientContainerWidth = this.refs.clientContainer.offsetWidth + if (clientContainerHeight !== this.measurements.clientContainerHeight) { + this.measurements.clientContainerHeight = clientContainerHeight dimensionsChanged = true } - if (scrollerWidth !== this.measurements.scrollerWidth) { - this.measurements.scrollerWidth = scrollerWidth + if (clientContainerWidth !== this.measurements.clientContainerWidth) { + this.measurements.clientContainerWidth = clientContainerWidth + this.props.model.setEditorWidthInChars(this.getScrollContainerWidth() / this.getBaseCharacterWidth()) dimensionsChanged = true } return dimensionsChanged } - measureScrollPosition () { - let scrollPositionChanged = false - const {scrollTop, scrollLeft} = this.refs.scroller - if (scrollTop !== this.measurements.scrollTop) { - this.measurements.scrollTop = scrollTop - scrollPositionChanged = true - } - if (scrollLeft !== this.measurements.scrollLeft) { - this.measurements.scrollLeft = scrollLeft - scrollPositionChanged = true - } - return scrollPositionChanged - } - - measureClientDimensions () { - const {clientHeight, clientWidth} = this.refs.scroller - if (clientHeight !== this.measurements.clientHeight) { - this.measurements.clientHeight = clientHeight - } - if (clientWidth !== this.measurements.clientWidth) { - this.measurements.clientWidth = clientWidth - this.props.model.setWidth(clientWidth - this.getGutterContainerWidth(), true) - } - } - measureCharacterDimensions () { this.measurements.lineHeight = this.refs.characterMeasurementLine.getBoundingClientRect().height this.measurements.baseCharacterWidth = this.refs.normalWidthCharacterSpan.getBoundingClientRect().width @@ -1383,7 +1309,7 @@ class TextEditorComponent { } pixelTopForRow (row) { - return row * this.measurements.lineHeight + return row * this.getLineHeight() } pixelLeftForRowAndColumn (row, column) { @@ -1478,73 +1404,91 @@ class TextEditorComponent { return this.element.offsetWidth > 0 || this.element.offsetHeight > 0 } + getLineHeight () { + return this.measurements.lineHeight + } + getBaseCharacterWidth () { return this.measurements ? this.measurements.baseCharacterWidth : null } - getScrollTop () { - if (this.measurements != null) { - return this.measurements.scrollTop + getLongestLineWidth () { + return this.measurements.longestLineWidth + } + + getClientContainerHeight () { + return this.measurements.clientContainerHeight + } + + getClientContainerWidth () { + return this.measurements.clientContainerWidth + } + + getScrollContainerWidth () { + if (this.props.model.getAutoWidth()) { + return this.getScrollWidth() + } else { + return this.getClientContainerWidth() - this.getGutterContainerWidth() } } - getScrollBottom () { - return this.measurements - ? this.measurements.scrollTop + this.measurements.clientHeight - : null + getScrollContainerHeight () { + if (this.props.model.getAutoHeight()) { + return this.getScrollHeight() + } else { + return this.getClientContainerHeight() + } } - getScrollLeft () { - return this.measurements ? this.measurements.scrollLeft : null + getScrollContainerHeightInLines () { + return Math.ceil(this.getScrollContainerHeight() / this.getLineHeight()) } - getScrollRight () { - return this.measurements - ? this.measurements.scrollLeft + this.measurements.clientWidth - : null + getScrollContainerClientWidth () { + return this.getScrollContainerWidth() + } + + getScrollContainerClientHeight () { + return this.getScrollContainerHeight() } getScrollHeight () { - const {model} = this.props - const contentHeight = model.getApproximateScreenLineCount() * this.measurements.lineHeight - if (model.getScrollPastEnd()) { - const extraScrollHeight = Math.max( - 3 * this.measurements.lineHeight, - this.getClientHeight() - 3 * this.measurements.lineHeight + if (this.props.model.getScrollPastEnd()) { + return this.getContentHeight() + Math.max( + 3 * this.getLineHeight(), + this.getScrollContainerClientHeight() - (3 * this.getLineHeight()) ) - return contentHeight + extraScrollHeight } else { - return contentHeight + return this.getContentHeight() } } getScrollWidth () { - return this.getContentWidth() + this.getGutterContainerWidth() + const {model} = this.props + + if (model.isSoftWrapped()) { + return this.getScrollContainerClientWidth() + } else if (model.getAutoWidth()) { + return this.getContentWidth() + } else { + return Math.max(this.getContentWidth(), this.getScrollContainerClientWidth()) + } } - getClientHeight () { - return this.measurements.clientHeight + getContentHeight () { + return this.props.model.getApproximateScreenLineCount() * this.getLineHeight() } - getClientWidth () { - return this.measurements.clientWidth + getContentWidth () { + return Math.round(this.getLongestLineWidth() + this.getBaseCharacterWidth()) } getGutterContainerWidth () { - return this.measurements.lineNumberGutterWidth + return this.getLineNumberGutterWidth() } - getContentWidth () { - if (this.props.model.isSoftWrapped()) { - return this.getClientWidth() - this.getGutterContainerWidth() - } else if (this.props.model.getAutoWidth()) { - return Math.round(this.measurements.longestLineWidth + this.measurements.baseCharacterWidth) - } else { - return Math.max( - Math.round(this.measurements.longestLineWidth + this.measurements.baseCharacterWidth), - this.measurements.scrollerWidth - this.getGutterContainerWidth() - ) - } + getLineNumberGutterWidth () { + return this.measurements.lineNumberGutterWidth } getRowsPerTile () { @@ -1583,16 +1527,13 @@ class TextEditorComponent { } getFirstVisibleRow () { - const scrollTop = this.getScrollTop() - const lineHeight = this.measurements.lineHeight - return Math.floor(scrollTop / lineHeight) + return Math.floor(this.getScrollTop() / this.getLineHeight()) } getLastVisibleRow () { - const {scrollerHeight, lineHeight} = this.measurements return Math.min( this.props.model.getApproximateScreenLineCount() - 1, - this.getFirstVisibleRow() + Math.ceil(scrollerHeight / lineHeight) + this.getFirstVisibleRow() + this.getScrollContainerHeightInLines() ) } @@ -1600,6 +1541,63 @@ class TextEditorComponent { return Math.floor((this.getLastVisibleRow() - this.getFirstVisibleRow()) / this.getRowsPerTile()) + 2 } + + getScrollTop () { + this.scrollTop = Math.min(this.getMaxScrollTop(), this.scrollTop) + return this.scrollTop + } + + setScrollTop (scrollTop, suppressUpdate = false) { + scrollTop = Math.round(Math.max(0, Math.min(this.getMaxScrollTop(), scrollTop))) + if (scrollTop !== this.scrollTop) { + this.scrollTop = scrollTop + if (!suppressUpdate) this.scheduleUpdate() + return true + } else { + return false + } + } + + getMaxScrollTop () { + return Math.max(0, this.getScrollHeight() - this.getScrollContainerClientHeight()) + } + + getScrollBottom () { + return this.getScrollTop() + this.getScrollContainerClientHeight() + } + + setScrollBottom (scrollBottom, suppressUpdate = false) { + return this.setScrollTop(scrollBottom - this.getScrollContainerClientHeight(), suppressUpdate) + } + + getScrollLeft () { + // this.scrollLeft = Math.min(this.getMaxScrollLeft(), this.scrollLeft) + return this.scrollLeft + } + + setScrollLeft (scrollLeft, suppressUpdate = false) { + scrollLeft = Math.round(Math.max(0, Math.min(this.getMaxScrollLeft(), scrollLeft))) + if (scrollLeft !== this.scrollLeft) { + this.scrollLeft = scrollLeft + if (!suppressUpdate) this.scheduleUpdate() + return true + } else { + return false + } + } + + getMaxScrollLeft () { + return Math.max(0, this.getScrollWidth() - this.getScrollContainerClientWidth()) + } + + getScrollRight () { + return this.getScrollLeft() + this.getScrollContainerClientWidth() + } + + setScrollRight (scrollRight, suppressUpdate = false) { + return this.setScrollLeft(scrollRight - this.getScrollContainerClientWidth(), suppressUpdate) + } + // Ensure the spatial index is populated with rows that are currently // visible so we *at least* get the longest row in the visible range. populateVisibleRowRange () { @@ -1608,7 +1606,7 @@ class TextEditorComponent { } topPixelPositionForRow (row) { - return row * this.measurements.lineHeight + return row * this.getLineHeight() } getNextUpdatePromise () { @@ -1829,7 +1827,7 @@ class LinesTileComponent { position: 'absolute', contain: 'strict', height: height + 'px', - width: width + 'px', + width: width + 'px' } }, children) } diff --git a/src/text-editor-element.js b/src/text-editor-element.js index 38bedfe0ec8..eb64e5fa748 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -18,7 +18,7 @@ class TextEditorElement extends HTMLElement { } getModel () { - return this.getComponent().getModel() + return this.getComponent().props.model } setModel (model) { From 2075f06404465833c14033b93a47eb5f210a7f4f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 20 Mar 2017 15:21:05 -0700 Subject: [PATCH 151/403] WIP: Introduce dummy scrollbars Still need tests on all of this --- spec/text-editor-component-spec.js | 5 +- src/atom-environment.coffee | 9 + src/text-editor-component.js | 260 +++++++++++++++++++++++++---- src/text-editor.coffee | 6 +- 4 files changed, 246 insertions(+), 34 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index e96d5ecd937..c209ce6effa 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -195,7 +195,6 @@ describe('TextEditorComponent', () => { jasmine.attachToDOM(element) expect(getBaseCharacterWidth(component)).toBe(55) - console.log('running expectation'); expect(lineNodeForScreenRow(component, 3).textContent).toBe( ' var pivot = items.shift(), current, left = [], ' ) @@ -337,7 +336,6 @@ describe('TextEditorComponent', () => { element.style.display = 'none' jasmine.attachToDOM(element) element.style.display = 'block' - console.log('focus in test'); element.focus() await component.getNextUpdatePromise() @@ -383,7 +381,6 @@ describe('TextEditorComponent', () => { it('does not vertically autoscroll by more than half of the visible lines if the editor is shorter than twice the scroll margin', async () => { const {component, element, editor} = buildComponent({autoHeight: false}) - const {scrollContainer} = component.refs element.style.height = 5.5 * component.measurements.lineHeight + 'px' await component.getNextUpdatePromise() expect(component.getLastVisibleRow()).toBe(6) @@ -430,7 +427,7 @@ describe('TextEditorComponent', () => { clientLeftForCharacter(component, 2, 28) - lineNodeForScreenRow(component, 2).getBoundingClientRect().left + (editor.horizontalScrollMargin * component.measurements.baseCharacterWidth) - - scrollContainer.clientWidth + component.getScrollContainerClientWidth() ) expect(component.getScrollLeft()).toBe(expectedScrollLeft) }) diff --git a/src/atom-environment.coffee b/src/atom-environment.coffee index 8ed491c159c..7167cc36a28 100644 --- a/src/atom-environment.coffee +++ b/src/atom-environment.coffee @@ -696,6 +696,11 @@ class AtomEnvironment extends Model callback = => @applicationDelegate.didSaveWindowState() @saveState({isUnloading: true}).catch(callback).then(callback) + didChangeStyles = @didChangeStyles.bind(this) + @disposables.add(@styles.onDidAddStyleElement(didChangeStyles)) + @disposables.add(@styles.onDidUpdateStyleElement(didChangeStyles)) + @disposables.add(@styles.onDidRemoveStyleElement(didChangeStyles)) + @listenForUpdates() @registerDefaultTargetForKeymaps() @@ -798,6 +803,10 @@ class AtomEnvironment extends Model @windowEventHandler?.unsubscribe() @windowEventHandler = null + didChangeStyles: (styleElement) -> + if styleElement.textContent.indexOf('scrollbar') >= 0 + TextEditor.didUpdateScrollbarStyles() + ### Section: Messaging the User ### diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 1016dcb7cce..ff199d49146 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -24,6 +24,14 @@ function scaleMouseDragAutoscrollDelta (delta) { module.exports = class TextEditorComponent { + static didUpdateScrollbarStyles () { + if (this.attachedComponents) { + this.attachedComponents.forEach((component) => { + component.didUpdateScrollbarStyles() + }) + } + } + constructor (props) { this.props = props @@ -47,6 +55,8 @@ class TextEditorComponent { this.horizontalPixelPositionsByScreenLineId = new Map() // Values are maps from column to horiontal pixel positions this.lineNodesByScreenLineId = new Map() this.textNodesByScreenLineId = new Map() + this.scrollbarsVisible = true + this.refreshScrollbarStyling = false this.pendingAutoscroll = null this.scrollTop = 0 this.scrollLeft = 0 @@ -66,7 +76,7 @@ class TextEditorComponent { cursors: [] } - if (this.props.model) this.observeModel() + this.observeModel() resizeDetector.listenTo(this.element, this.didResize.bind(this)) etch.updateSync(this) @@ -98,26 +108,38 @@ class TextEditorComponent { this.resolveNextUpdatePromise = null } + const wasHorizontalScrollbarVisible = this.isHorizontalScrollbarVisible() this.horizontalPositionsToMeasure.clear() if (this.pendingAutoscroll) this.autoscrollVertically() this.populateVisibleRowRange() const longestLineToMeasure = this.checkForNewLongestLine() this.queryScreenLinesToRender() this.queryDecorationsToRender() + this.scrollbarsVisible = !this.refreshScrollbarStyling etch.updateSync(this) this.measureHorizontalPositions() if (longestLineToMeasure) this.measureLongestLineWidth(longestLineToMeasure) this.updateAbsolutePositionedDecorations() - - etch.updateSync(this) - if (this.pendingAutoscroll) { this.autoscrollHorizontally() + if (!wasHorizontalScrollbarVisible && this.isHorizontalScrollbarVisible()) { + this.autoscrollVertically() + } this.pendingAutoscroll = null } + this.scrollbarsVisible = true + + etch.updateSync(this) + this.currentFrameLineNumberGutterProps = null + + if (this.refreshScrollbarStyling) { + this.measureScrollbarDimensions() + this.refreshScrollbarStyling = false + etch.updateSync(this) + } } checkIfScrollDimensionsChanged () { @@ -134,11 +156,12 @@ class TextEditorComponent { render () { const {model} = this.props - const style = {} + if (!model.getAutoHeight() && !model.getAutoWidth()) { style.contain = 'strict' } + if (this.measurements) { if (model.getAutoHeight()) { style.height = this.getContentHeight() + 'px' @@ -294,7 +317,8 @@ class TextEditorComponent { className: 'scroll-view', style }, - this.renderContent() + this.renderContent(), + this.renderDummyScrollbars() ) } @@ -478,6 +502,65 @@ class TextEditorComponent { }) } + renderDummyScrollbars () { + if (this.scrollbarsVisible) { + let scrollHeight, scrollTop, horizontalScrollbarHeight, + scrollWidth, scrollLeft, verticalScrollbarWidth, forceScrollbarVisible + + if (this.measurements) { + scrollHeight = this.getScrollHeight() + scrollWidth = this.getScrollWidth() + scrollTop = this.getScrollTop() + scrollLeft = this.getScrollLeft() + horizontalScrollbarHeight = + this.isHorizontalScrollbarVisible() + ? this.getHorizontalScrollbarHeight() + : 0 + verticalScrollbarWidth = + this.isVerticalScrollbarVisible() + ? this.getVerticalScrollbarWidth() + : 0 + forceScrollbarVisible = this.refreshScrollbarStyling + } else { + forceScrollbarVisible = true + } + + const elements = [ + $(DummyScrollbarComponent, { + ref: 'verticalScrollbar', + orientation: 'vertical', + scrollHeight, scrollTop, horizontalScrollbarHeight, forceScrollbarVisible + }), + $(DummyScrollbarComponent, { + ref: 'horizontalScrollbar', + orientation: 'horizontal', + scrollWidth, scrollLeft, verticalScrollbarWidth, forceScrollbarVisible + }) + ] + + // If both scrollbars are visible, push a dummy element to force a "corner" + // to render where the two scrollbars meet at the lower right + if (verticalScrollbarWidth > 0 && horizontalScrollbarHeight > 0) { + elements.push($.div( + { + style: { + position: 'absolute', + height: '20px', + width: '20px', + bottom: 0, + right: 0, + overflow: 'scroll' + } + } + )) + } + + return elements + } else { + return null + } + } + // This is easier to mock getPlatform () { return process.platform @@ -679,6 +762,10 @@ class TextEditorComponent { } else { this.didHide() } + if (!this.constructor.attachedComponents) { + this.constructor.attachedComponents = new Set() + } + this.constructor.attachedComponents.add(this) } } @@ -686,6 +773,7 @@ class TextEditorComponent { if (this.attached) { this.didHide() this.attached = false + this.constructor.attachedComponents.delete(this) } } @@ -781,6 +869,11 @@ class TextEditorComponent { } } + didUpdateScrollbarStyles () { + this.refreshScrollbarStyling = true + this.scheduleUpdate() + } + didTextInput (event) { event.stopPropagation() @@ -1175,6 +1268,30 @@ class TextEditorComponent { this.measureCharacterDimensions() this.measureGutterDimensions() this.measureClientContainerDimensions() + this.measureScrollbarDimensions() + } + + measureCharacterDimensions () { + this.measurements.lineHeight = this.refs.characterMeasurementLine.getBoundingClientRect().height + this.measurements.baseCharacterWidth = this.refs.normalWidthCharacterSpan.getBoundingClientRect().width + this.measurements.doubleWidthCharacterWidth = this.refs.doubleWidthCharacterSpan.getBoundingClientRect().width + this.measurements.halfWidthCharacterWidth = this.refs.halfWidthCharacterSpan.getBoundingClientRect().width + this.measurements.koreanCharacterWidth = this.refs.koreanCharacterSpan.getBoundingClientRect().widt + + this.props.model.setDefaultCharWidth( + this.measurements.baseCharacterWidth, + this.measurements.doubleWidthCharacterWidth, + this.measurements.halfWidthCharacterWidth, + this.measurements.koreanCharacterWidth + ) + } + + measureGutterDimensions () { + if (this.refs.lineNumberGutter) { + this.measurements.lineNumberGutterWidth = this.refs.lineNumberGutter.offsetWidth + } else { + this.measurements.lineNumberGutterWidth = 0 + } } measureClientContainerDimensions () { @@ -1195,19 +1312,9 @@ class TextEditorComponent { return dimensionsChanged } - measureCharacterDimensions () { - this.measurements.lineHeight = this.refs.characterMeasurementLine.getBoundingClientRect().height - this.measurements.baseCharacterWidth = this.refs.normalWidthCharacterSpan.getBoundingClientRect().width - this.measurements.doubleWidthCharacterWidth = this.refs.doubleWidthCharacterSpan.getBoundingClientRect().width - this.measurements.halfWidthCharacterWidth = this.refs.halfWidthCharacterSpan.getBoundingClientRect().width - this.measurements.koreanCharacterWidth = this.refs.koreanCharacterSpan.getBoundingClientRect().widt - - this.props.model.setDefaultCharWidth( - this.measurements.baseCharacterWidth, - this.measurements.doubleWidthCharacterWidth, - this.measurements.halfWidthCharacterWidth, - this.measurements.koreanCharacterWidth - ) + measureScrollbarDimensions () { + this.measurements.verticalScrollbarWidth = this.refs.verticalScrollbar.getRealScrollbarWidth() + this.measurements.horizontalScrollbarHeight = this.refs.horizontalScrollbar.getRealScrollbarHeight() } checkForNewLongestLine () { @@ -1228,14 +1335,6 @@ class TextEditorComponent { this.longestLineToMeasure = null } - measureGutterDimensions () { - if (this.refs.lineNumberGutter) { - this.measurements.lineNumberGutterWidth = this.refs.lineNumberGutter.offsetWidth - } else { - this.measurements.lineNumberGutterWidth = 0 - } - } - requestHorizontalMeasurement (row, column) { if (column === 0) return let columns = this.horizontalPositionsToMeasure.get(row) @@ -1445,11 +1544,48 @@ class TextEditorComponent { } getScrollContainerClientWidth () { - return this.getScrollContainerWidth() + if (this.isVerticalScrollbarVisible()) { + return this.getScrollContainerWidth() - this.getVerticalScrollbarWidth() + } else { + return this.getScrollContainerWidth() + } } getScrollContainerClientHeight () { - return this.getScrollContainerHeight() + if (this.isHorizontalScrollbarVisible()) { + return this.getScrollContainerHeight() - this.getHorizontalScrollbarHeight() + } else { + return this.getScrollContainerHeight() + } + } + + isVerticalScrollbarVisible () { + return ( + this.getContentHeight() > this.getScrollContainerHeight() || + this.isContentMinimallyOverlappingBothScrollbars() + ) + } + + isHorizontalScrollbarVisible () { + return ( + !this.props.model.isSoftWrapped() && + ( + this.getContentWidth() > this.getScrollContainerWidth() || + this.isContentMinimallyOverlappingBothScrollbars() + ) + ) + } + + isContentMinimallyOverlappingBothScrollbars () { + const clientHeightWithHorizontalScrollbar = + this.getScrollContainerHeight() - this.getHorizontalScrollbarHeight() + const clientWidthWithVerticalScrollbar = + this.getScrollContainerWidth() - this.getVerticalScrollbarWidth() + + return ( + this.getContentHeight() > clientHeightWithHorizontalScrollbar && + this.getContentWidth() > clientWidthWithVerticalScrollbar + ) } getScrollHeight () { @@ -1491,6 +1627,14 @@ class TextEditorComponent { return this.measurements.lineNumberGutterWidth } + getVerticalScrollbarWidth () { + return this.measurements.verticalScrollbarWidth + } + + getHorizontalScrollbarHeight () { + return this.measurements.horizontalScrollbarHeight + } + getRowsPerTile () { return this.props.rowsPerTile || DEFAULT_ROWS_PER_TILE } @@ -1619,6 +1763,64 @@ class TextEditorComponent { } } +class DummyScrollbarComponent { + constructor (props) { + this.props = props + etch.initialize(this) + } + + update (props) { + this.props = props + etch.updateSync(this) + } + + render () { + let scrollTop = 0 + let scrollLeft = 0 + const outerStyle = { + position: 'absolute', + contain: 'strict', + zIndex: 1 + } + const innerStyle = {} + if (this.props.orientation === 'horizontal') { + scrollLeft = this.props.scrollLeft || 0 + let right = (this.props.verticalScrollbarWidth || 0) + outerStyle.bottom = 0 + outerStyle.left = 0 + outerStyle.right = right + 'px' + outerStyle.height = '20px' + outerStyle.overflowY = 'hidden' + outerStyle.overflowX = this.props.forceScrollbarVisible ? 'scroll' : 'auto' + innerStyle.height = '20px' + innerStyle.width = (this.props.scrollWidth || 0) + 'px' + } else { + scrollTop = this.props.scrollTop || 0 + let bottom = (this.props.horizontalScrollbarHeight || 0) + outerStyle.right = 0 + outerStyle.top = 0 + outerStyle.bottom = bottom + 'px' + outerStyle.width = '20px' + outerStyle.overflowX = 'hidden' + outerStyle.overflowY = this.props.forceScrollbarVisible ? 'scroll' : 'auto' + innerStyle.width = '20px' + innerStyle.height = (this.props.scrollHeight || 0) + 'px' + } + + return $.div({style: outerStyle, scrollTop, scrollLeft}, + $.div({style: innerStyle}) + ) + } + + getRealScrollbarWidth () { + return this.element.offsetWidth - this.element.clientWidth + } + + getRealScrollbarHeight () { + return this.element.offsetHeight - this.element.clientHeight + } +} + class LineNumberGutterComponent { constructor (props) { this.props = props diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 4664168da84..442858434c3 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -61,6 +61,10 @@ class TextEditor extends Model @setClipboard: (clipboard) -> @clipboard = clipboard + @didUpdateScrollbarStyles: -> + TextEditorComponent ?= require './text-editor-component' + TextEditorComponent.didUpdateScrollbarStyles() + serializationVersion: 1 buffer: null @@ -3561,7 +3565,7 @@ class TextEditor extends Model @component.element else TextEditorComponent ?= require('./text-editor-component') - new TextEditorComponent({model: this}) + new TextEditorComponent({model: this, styleManager: atom.styles}) @component.element # Essential: Retrieves the greyed out placeholder of a mini editor. From 5757d6de859ff21b5ba609a22c9953753d953842 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 20 Mar 2017 18:35:26 -0700 Subject: [PATCH 152/403] Group rendering tests --- spec/text-editor-component-spec.js | 426 +++++++++++++++-------------- 1 file changed, 214 insertions(+), 212 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index c209ce6effa..d6c4bd7c83e 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -26,238 +26,240 @@ describe('TextEditorComponent', () => { jasmine.useRealClock() }) - it('renders lines and line numbers for the visible region', async () => { - const {component, element, editor} = buildComponent({rowsPerTile: 3, autoHeight: false}) - - expect(element.querySelectorAll('.line-number').length).toBe(13) - expect(element.querySelectorAll('.line').length).toBe(13) - - element.style.height = 4 * component.measurements.lineHeight + 'px' - await component.getNextUpdatePromise() - expect(element.querySelectorAll('.line-number').length).toBe(9) - expect(element.querySelectorAll('.line').length).toBe(9) - - component.setScrollTop(5 * component.getLineHeight()) - await component.getNextUpdatePromise() - - // After scrolling down beyond > 3 rows, the order of line numbers and lines - // in the DOM is a bit weird because the first tile is recycled to the bottom - // when it is scrolled out of view - expect(Array.from(element.querySelectorAll('.line-number')).map(element => element.textContent.trim())).toEqual([ - '10', '11', '12', '4', '5', '6', '7', '8', '9' - ]) - expect(Array.from(element.querySelectorAll('.line')).map(element => element.textContent)).toEqual([ - editor.lineTextForScreenRow(9), - ' ', // this line is blank in the model, but we render a space to prevent the line from collapsing vertically - editor.lineTextForScreenRow(11), - editor.lineTextForScreenRow(3), - editor.lineTextForScreenRow(4), - editor.lineTextForScreenRow(5), - editor.lineTextForScreenRow(6), - editor.lineTextForScreenRow(7), - editor.lineTextForScreenRow(8) - ]) - - component.setScrollTop(2.5 * component.getLineHeight()) - await component.getNextUpdatePromise() - expect(Array.from(element.querySelectorAll('.line-number')).map(element => element.textContent.trim())).toEqual([ - '1', '2', '3', '4', '5', '6', '7', '8', '9' - ]) - expect(Array.from(element.querySelectorAll('.line')).map(element => element.textContent)).toEqual([ - editor.lineTextForScreenRow(0), - editor.lineTextForScreenRow(1), - editor.lineTextForScreenRow(2), - editor.lineTextForScreenRow(3), - editor.lineTextForScreenRow(4), - editor.lineTextForScreenRow(5), - editor.lineTextForScreenRow(6), - editor.lineTextForScreenRow(7), - editor.lineTextForScreenRow(8) - ]) - }) + describe('rendering', () => { + it('renders lines and line numbers for the visible region', async () => { + const {component, element, editor} = buildComponent({rowsPerTile: 3, autoHeight: false}) - it('bases the width of the lines div on the width of the longest initially-visible screen line', () => { - const {component, element, editor} = buildComponent({rowsPerTile: 2, height: 20}) + expect(element.querySelectorAll('.line-number').length).toBe(13) + expect(element.querySelectorAll('.line').length).toBe(13) - expect(editor.getApproximateLongestScreenRow()).toBe(3) - const expectedWidth = element.querySelectorAll('.line')[3].offsetWidth - expect(element.querySelector('.lines').style.width).toBe(expectedWidth + 'px') + element.style.height = 4 * component.measurements.lineHeight + 'px' + await component.getNextUpdatePromise() + expect(element.querySelectorAll('.line-number').length).toBe(9) + expect(element.querySelectorAll('.line').length).toBe(9) - // TODO: Confirm that we'll update this value as indexing proceeds - }) + component.setScrollTop(5 * component.getLineHeight()) + await component.getNextUpdatePromise() - it('honors the scrollPastEnd option by adding empty space equivalent to the clientHeight to the end of the content area', async () => { - const {component, element, editor} = buildComponent({autoHeight: false, autoWidth: false}) - const {scrollContainer} = component.refs + // After scrolling down beyond > 3 rows, the order of line numbers and lines + // in the DOM is a bit weird because the first tile is recycled to the bottom + // when it is scrolled out of view + expect(Array.from(element.querySelectorAll('.line-number')).map(element => element.textContent.trim())).toEqual([ + '10', '11', '12', '4', '5', '6', '7', '8', '9' + ]) + expect(Array.from(element.querySelectorAll('.line')).map(element => element.textContent)).toEqual([ + editor.lineTextForScreenRow(9), + ' ', // this line is blank in the model, but we render a space to prevent the line from collapsing vertically + editor.lineTextForScreenRow(11), + editor.lineTextForScreenRow(3), + editor.lineTextForScreenRow(4), + editor.lineTextForScreenRow(5), + editor.lineTextForScreenRow(6), + editor.lineTextForScreenRow(7), + editor.lineTextForScreenRow(8) + ]) + + component.setScrollTop(2.5 * component.getLineHeight()) + await component.getNextUpdatePromise() + expect(Array.from(element.querySelectorAll('.line-number')).map(element => element.textContent.trim())).toEqual([ + '1', '2', '3', '4', '5', '6', '7', '8', '9' + ]) + expect(Array.from(element.querySelectorAll('.line')).map(element => element.textContent)).toEqual([ + editor.lineTextForScreenRow(0), + editor.lineTextForScreenRow(1), + editor.lineTextForScreenRow(2), + editor.lineTextForScreenRow(3), + editor.lineTextForScreenRow(4), + editor.lineTextForScreenRow(5), + editor.lineTextForScreenRow(6), + editor.lineTextForScreenRow(7), + editor.lineTextForScreenRow(8) + ]) + }) - await editor.update({scrollPastEnd: true}) - await setEditorHeightInLines(component, 6) + it('bases the width of the lines div on the width of the longest initially-visible screen line', () => { + const {component, element, editor} = buildComponent({rowsPerTile: 2, height: 20}) - // scroll to end - component.setScrollTop(scrollContainer.scrollHeight - scrollContainer.clientHeight) - await component.getNextUpdatePromise() - expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() - 3) + expect(editor.getApproximateLongestScreenRow()).toBe(3) + const expectedWidth = element.querySelectorAll('.line')[3].offsetWidth + expect(element.querySelector('.lines').style.width).toBe(expectedWidth + 'px') - editor.update({scrollPastEnd: false}) - await component.getNextUpdatePromise() // wait for scrollable content resize - expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() - 6) + // TODO: Confirm that we'll update this value as indexing proceeds + }) - // Always allows at least 3 lines worth of overscroll if the editor is short - await setEditorHeightInLines(component, 2) - await editor.update({scrollPastEnd: true}) - component.setScrollTop(scrollContainer.scrollHeight - scrollContainer.clientHeight) - await component.getNextUpdatePromise() - expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() + 1) - }) + it('honors the scrollPastEnd option by adding empty space equivalent to the clientHeight to the end of the content area', async () => { + const {component, element, editor} = buildComponent({autoHeight: false, autoWidth: false}) + const {scrollContainer} = component.refs - it('gives the line number gutter an explicit width and height so its layout can be strictly contained', () => { - const {component, element, editor} = buildComponent({rowsPerTile: 3}) + await editor.update({scrollPastEnd: true}) + await setEditorHeightInLines(component, 6) - const gutterElement = element.querySelector('.gutter.line-numbers') - expect(gutterElement.style.width).toBe(element.querySelector('.line-number').offsetWidth + 'px') - expect(gutterElement.style.height).toBe(editor.getScreenLineCount() * component.measurements.lineHeight + 'px') - expect(gutterElement.style.contain).toBe('strict') + // scroll to end + component.setScrollTop(scrollContainer.scrollHeight - scrollContainer.clientHeight) + await component.getNextUpdatePromise() + expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() - 3) - // Tile nodes also have explicit width and height assignment - expect(gutterElement.firstChild.style.width).toBe(element.querySelector('.line-number').offsetWidth + 'px') - expect(gutterElement.firstChild.style.height).toBe(3 * component.measurements.lineHeight + 'px') - expect(gutterElement.firstChild.style.contain).toBe('strict') - }) + editor.update({scrollPastEnd: false}) + await component.getNextUpdatePromise() // wait for scrollable content resize + expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() - 6) - it('renders cursors within the visible row range', async () => { - const {component, element, editor} = buildComponent({height: 40, rowsPerTile: 2}) - component.setScrollTop(100) - await component.getNextUpdatePromise() - - expect(component.getRenderedStartRow()).toBe(4) - expect(component.getRenderedEndRow()).toBe(10) - - editor.setCursorScreenPosition([0, 0], {autoscroll: false}) // out of view - editor.addCursorAtScreenPosition([2, 2], {autoscroll: false}) // out of view - editor.addCursorAtScreenPosition([4, 0], {autoscroll: false}) // line start - editor.addCursorAtScreenPosition([4, 4], {autoscroll: false}) // at token boundary - editor.addCursorAtScreenPosition([4, 6], {autoscroll: false}) // within token - editor.addCursorAtScreenPosition([5, Infinity], {autoscroll: false}) // line end - editor.addCursorAtScreenPosition([10, 2], {autoscroll: false}) // out of view - await component.getNextUpdatePromise() - - let cursorNodes = Array.from(element.querySelectorAll('.cursor')) - expect(cursorNodes.length).toBe(4) - verifyCursorPosition(component, cursorNodes[0], 4, 0) - verifyCursorPosition(component, cursorNodes[1], 4, 4) - verifyCursorPosition(component, cursorNodes[2], 4, 6) - verifyCursorPosition(component, cursorNodes[3], 5, 30) - - editor.setCursorScreenPosition([8, 11], {autoscroll: false}) - await component.getNextUpdatePromise() - - cursorNodes = Array.from(element.querySelectorAll('.cursor')) - expect(cursorNodes.length).toBe(1) - verifyCursorPosition(component, cursorNodes[0], 8, 11) - - editor.setCursorScreenPosition([0, 0], {autoscroll: false}) - await component.getNextUpdatePromise() - - cursorNodes = Array.from(element.querySelectorAll('.cursor')) - expect(cursorNodes.length).toBe(0) - - editor.setSelectedScreenRange([[8, 0], [12, 0]], {autoscroll: false}) - await component.getNextUpdatePromise() - cursorNodes = Array.from(element.querySelectorAll('.cursor')) - expect(cursorNodes.length).toBe(0) - }) + // Always allows at least 3 lines worth of overscroll if the editor is short + await setEditorHeightInLines(component, 2) + await editor.update({scrollPastEnd: true}) + component.setScrollTop(scrollContainer.scrollHeight - scrollContainer.clientHeight) + await component.getNextUpdatePromise() + expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() + 1) + }) - it('places the hidden input element at the location of the last cursor if it is visible', async () => { - const {component, element, editor} = buildComponent({height: 60, width: 120, rowsPerTile: 2}) - const {hiddenInput} = component.refs - component.setScrollTop(100) - component.setScrollLeft(40) - await component.getNextUpdatePromise() - - expect(component.getRenderedStartRow()).toBe(4) - expect(component.getRenderedEndRow()).toBe(12) - - // When out of view, the hidden input is positioned at 0, 0 - expect(editor.getCursorScreenPosition()).toEqual([0, 0]) - expect(hiddenInput.offsetTop).toBe(0) - expect(hiddenInput.offsetLeft).toBe(0) - - // Otherwise it is positioned at the last cursor position - editor.addCursorAtScreenPosition([7, 4]) - await component.getNextUpdatePromise() - expect(hiddenInput.getBoundingClientRect().top).toBe(clientTopForLine(component, 7)) - expect(Math.round(hiddenInput.getBoundingClientRect().left)).toBe(clientLeftForCharacter(component, 7, 4)) - }) + it('gives the line number gutter an explicit width and height so its layout can be strictly contained', () => { + const {component, element, editor} = buildComponent({rowsPerTile: 3}) - it('soft wraps lines based on the content width when soft wrap is enabled', async () => { - const {component, element, editor} = buildComponent({width: 435, attach: false}) - editor.setSoftWrapped(true) - jasmine.attachToDOM(element) - - expect(getBaseCharacterWidth(component)).toBe(55) - expect(lineNodeForScreenRow(component, 3).textContent).toBe( - ' var pivot = items.shift(), current, left = [], ' - ) - expect(lineNodeForScreenRow(component, 4).textContent).toBe( - ' right = [];' - ) - - await setEditorWidthInCharacters(component, 45) - expect(lineNodeForScreenRow(component, 3).textContent).toBe( - ' var pivot = items.shift(), current, left ' - ) - expect(lineNodeForScreenRow(component, 4).textContent).toBe( - ' = [], right = [];' - ) - - const {scrollContainer} = component.refs - expect(scrollContainer.clientWidth).toBe(scrollContainer.scrollWidth) - }) + const gutterElement = element.querySelector('.gutter.line-numbers') + expect(gutterElement.style.width).toBe(element.querySelector('.line-number').offsetWidth + 'px') + expect(gutterElement.style.height).toBe(editor.getScreenLineCount() * component.measurements.lineHeight + 'px') + expect(gutterElement.style.contain).toBe('strict') - it('decorates the line numbers of folded lines', async () => { - const {component, element, editor} = buildComponent() - editor.foldBufferRow(1) - await component.getNextUpdatePromise() - expect(lineNumberNodeForScreenRow(component, 1).classList.contains('folded')).toBe(true) - }) + // Tile nodes also have explicit width and height assignment + expect(gutterElement.firstChild.style.width).toBe(element.querySelector('.line-number').offsetWidth + 'px') + expect(gutterElement.firstChild.style.height).toBe(3 * component.measurements.lineHeight + 'px') + expect(gutterElement.firstChild.style.contain).toBe('strict') + }) - it('makes lines at least as wide as the scrollContainer', async () => { - const {component, element, editor} = buildComponent() - const {scrollContainer, gutterContainer} = component.refs - editor.setText('a') - await component.getNextUpdatePromise() + it('renders cursors within the visible row range', async () => { + const {component, element, editor} = buildComponent({height: 40, rowsPerTile: 2}) + component.setScrollTop(100) + await component.getNextUpdatePromise() - expect(element.querySelector('.line').offsetWidth).toBe(scrollContainer.offsetWidth) - }) + expect(component.getRenderedStartRow()).toBe(4) + expect(component.getRenderedEndRow()).toBe(10) - it('resizes based on the content when the autoHeight and/or autoWidth options are true', async () => { - const {component, element, editor} = buildComponent({autoHeight: true, autoWidth: true}) - const {gutterContainer, scrollContainer} = component.refs - const initialWidth = element.offsetWidth - const initialHeight = element.offsetHeight - expect(initialWidth).toBe(gutterContainer.offsetWidth + scrollContainer.scrollWidth) - expect(initialHeight).toBe(scrollContainer.scrollHeight) - editor.setCursorScreenPosition([6, Infinity]) - editor.insertText('x'.repeat(50)) - await component.getNextUpdatePromise() - expect(element.offsetWidth).toBe(gutterContainer.offsetWidth + scrollContainer.scrollWidth) - expect(element.offsetWidth).toBeGreaterThan(initialWidth) - editor.insertText('\n'.repeat(5)) - await component.getNextUpdatePromise() - expect(element.offsetHeight).toBe(scrollContainer.scrollHeight) - expect(element.offsetHeight).toBeGreaterThan(initialHeight) - }) + editor.setCursorScreenPosition([0, 0], {autoscroll: false}) // out of view + editor.addCursorAtScreenPosition([2, 2], {autoscroll: false}) // out of view + editor.addCursorAtScreenPosition([4, 0], {autoscroll: false}) // line start + editor.addCursorAtScreenPosition([4, 4], {autoscroll: false}) // at token boundary + editor.addCursorAtScreenPosition([4, 6], {autoscroll: false}) // within token + editor.addCursorAtScreenPosition([5, Infinity], {autoscroll: false}) // line end + editor.addCursorAtScreenPosition([10, 2], {autoscroll: false}) // out of view + await component.getNextUpdatePromise() - it('supports the isLineNumberGutterVisible parameter', () => { - const {component, element, editor} = buildComponent({lineNumberGutterVisible: false}) - expect(element.querySelector('.line-number')).toBe(null) - }) + let cursorNodes = Array.from(element.querySelectorAll('.cursor')) + expect(cursorNodes.length).toBe(4) + verifyCursorPosition(component, cursorNodes[0], 4, 0) + verifyCursorPosition(component, cursorNodes[1], 4, 4) + verifyCursorPosition(component, cursorNodes[2], 4, 6) + verifyCursorPosition(component, cursorNodes[3], 5, 30) + + editor.setCursorScreenPosition([8, 11], {autoscroll: false}) + await component.getNextUpdatePromise() + + cursorNodes = Array.from(element.querySelectorAll('.cursor')) + expect(cursorNodes.length).toBe(1) + verifyCursorPosition(component, cursorNodes[0], 8, 11) + + editor.setCursorScreenPosition([0, 0], {autoscroll: false}) + await component.getNextUpdatePromise() + + cursorNodes = Array.from(element.querySelectorAll('.cursor')) + expect(cursorNodes.length).toBe(0) + + editor.setSelectedScreenRange([[8, 0], [12, 0]], {autoscroll: false}) + await component.getNextUpdatePromise() + cursorNodes = Array.from(element.querySelectorAll('.cursor')) + expect(cursorNodes.length).toBe(0) + }) + + it('places the hidden input element at the location of the last cursor if it is visible', async () => { + const {component, element, editor} = buildComponent({height: 60, width: 120, rowsPerTile: 2}) + const {hiddenInput} = component.refs + component.setScrollTop(100) + component.setScrollLeft(40) + await component.getNextUpdatePromise() + + expect(component.getRenderedStartRow()).toBe(4) + expect(component.getRenderedEndRow()).toBe(12) - it('supports the placeholderText parameter', () => { - const placeholderText = 'Placeholder Test' - const {component} = buildComponent({placeholderText, text: ''}) - const emptyLineSpace = ' ' - expect(component.refs.content.textContent).toBe(emptyLineSpace + placeholderText) + // When out of view, the hidden input is positioned at 0, 0 + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + expect(hiddenInput.offsetTop).toBe(0) + expect(hiddenInput.offsetLeft).toBe(0) + + // Otherwise it is positioned at the last cursor position + editor.addCursorAtScreenPosition([7, 4]) + await component.getNextUpdatePromise() + expect(hiddenInput.getBoundingClientRect().top).toBe(clientTopForLine(component, 7)) + expect(Math.round(hiddenInput.getBoundingClientRect().left)).toBe(clientLeftForCharacter(component, 7, 4)) + }) + + it('soft wraps lines based on the content width when soft wrap is enabled', async () => { + const {component, element, editor} = buildComponent({width: 435, attach: false}) + editor.setSoftWrapped(true) + jasmine.attachToDOM(element) + + expect(getBaseCharacterWidth(component)).toBe(55) + expect(lineNodeForScreenRow(component, 3).textContent).toBe( + ' var pivot = items.shift(), current, left = [], ' + ) + expect(lineNodeForScreenRow(component, 4).textContent).toBe( + ' right = [];' + ) + + await setEditorWidthInCharacters(component, 45) + expect(lineNodeForScreenRow(component, 3).textContent).toBe( + ' var pivot = items.shift(), current, left ' + ) + expect(lineNodeForScreenRow(component, 4).textContent).toBe( + ' = [], right = [];' + ) + + const {scrollContainer} = component.refs + expect(scrollContainer.clientWidth).toBe(scrollContainer.scrollWidth) + }) + + it('decorates the line numbers of folded lines', async () => { + const {component, element, editor} = buildComponent() + editor.foldBufferRow(1) + await component.getNextUpdatePromise() + expect(lineNumberNodeForScreenRow(component, 1).classList.contains('folded')).toBe(true) + }) + + it('makes lines at least as wide as the scrollContainer', async () => { + const {component, element, editor} = buildComponent() + const {scrollContainer, gutterContainer} = component.refs + editor.setText('a') + await component.getNextUpdatePromise() + + expect(element.querySelector('.line').offsetWidth).toBe(scrollContainer.offsetWidth) + }) + + it('resizes based on the content when the autoHeight and/or autoWidth options are true', async () => { + const {component, element, editor} = buildComponent({autoHeight: true, autoWidth: true}) + const {gutterContainer, scrollContainer} = component.refs + const initialWidth = element.offsetWidth + const initialHeight = element.offsetHeight + expect(initialWidth).toBe(gutterContainer.offsetWidth + scrollContainer.scrollWidth) + expect(initialHeight).toBe(scrollContainer.scrollHeight) + editor.setCursorScreenPosition([6, Infinity]) + editor.insertText('x'.repeat(50)) + await component.getNextUpdatePromise() + expect(element.offsetWidth).toBe(gutterContainer.offsetWidth + scrollContainer.scrollWidth) + expect(element.offsetWidth).toBeGreaterThan(initialWidth) + editor.insertText('\n'.repeat(5)) + await component.getNextUpdatePromise() + expect(element.offsetHeight).toBe(scrollContainer.scrollHeight) + expect(element.offsetHeight).toBeGreaterThan(initialHeight) + }) + + it('supports the isLineNumberGutterVisible parameter', () => { + const {component, element, editor} = buildComponent({lineNumberGutterVisible: false}) + expect(element.querySelector('.line-number')).toBe(null) + }) + + it('supports the placeholderText parameter', () => { + const placeholderText = 'Placeholder Test' + const {component} = buildComponent({placeholderText, text: ''}) + const emptyLineSpace = ' ' + expect(component.refs.content.textContent).toBe(emptyLineSpace + placeholderText) + }) }) describe('mini editors', () => { From 8720dbc8625e40441334c363acabfc9bd72ead55 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 20 Mar 2017 18:36:25 -0700 Subject: [PATCH 153/403] :art: --- spec/text-editor-component-spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index d6c4bd7c83e..79f9501abef 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -359,7 +359,7 @@ describe('TextEditorComponent', () => { }) }) - describe('autoscroll on cursor movement', () => { + describe('autoscroll', () => { it('automatically scrolls vertically when the requested range is within the vertical scroll margin of the top or bottom', async () => { const {component, editor} = buildComponent({height: 120}) expect(component.getLastVisibleRow()).toBe(8) From 0999d0bf02ef6004f8cdbe237c24c635801a38a3 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 21 Mar 2017 10:10:26 -0600 Subject: [PATCH 154/403] Handle scrolling of the dummy scrollbars directly --- spec/text-editor-component-spec.js | 119 ++++++++++++++++++++++++----- src/text-editor-component.js | 61 ++++++++++----- 2 files changed, 141 insertions(+), 39 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 79f9501abef..841008b37db 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -38,8 +38,7 @@ describe('TextEditorComponent', () => { expect(element.querySelectorAll('.line-number').length).toBe(9) expect(element.querySelectorAll('.line').length).toBe(9) - component.setScrollTop(5 * component.getLineHeight()) - await component.getNextUpdatePromise() + await setScrollTop(component, 5 * component.getLineHeight()) // After scrolling down beyond > 3 rows, the order of line numbers and lines // in the DOM is a bit weird because the first tile is recycled to the bottom @@ -59,8 +58,7 @@ describe('TextEditorComponent', () => { editor.lineTextForScreenRow(8) ]) - component.setScrollTop(2.5 * component.getLineHeight()) - await component.getNextUpdatePromise() + await setScrollTop(component, 2.5 * component.getLineHeight()) expect(Array.from(element.querySelectorAll('.line-number')).map(element => element.textContent.trim())).toEqual([ '1', '2', '3', '4', '5', '6', '7', '8', '9' ]) @@ -95,8 +93,7 @@ describe('TextEditorComponent', () => { await setEditorHeightInLines(component, 6) // scroll to end - component.setScrollTop(scrollContainer.scrollHeight - scrollContainer.clientHeight) - await component.getNextUpdatePromise() + await setScrollTop(component, scrollContainer.scrollHeight - scrollContainer.clientHeight) expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() - 3) editor.update({scrollPastEnd: false}) @@ -106,8 +103,7 @@ describe('TextEditorComponent', () => { // Always allows at least 3 lines worth of overscroll if the editor is short await setEditorHeightInLines(component, 2) await editor.update({scrollPastEnd: true}) - component.setScrollTop(scrollContainer.scrollHeight - scrollContainer.clientHeight) - await component.getNextUpdatePromise() + await setScrollTop(component, scrollContainer.scrollHeight - scrollContainer.clientHeight) expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() + 1) }) @@ -125,10 +121,73 @@ describe('TextEditorComponent', () => { expect(gutterElement.firstChild.style.contain).toBe('strict') }) + it('renders dummy vertical and horizontal scrollbars when content overflows', async () => { + const {component, element, editor} = buildComponent({height: 100, width: 100}) + const verticalScrollbar = component.refs.verticalScrollbar.element + const horizontalScrollbar = component.refs.horizontalScrollbar.element + expect(verticalScrollbar.scrollHeight).toBe(component.getContentHeight()) + expect(horizontalScrollbar.scrollWidth).toBe(component.getContentWidth()) + expect(getVerticalScrollbarWidth(component)).toBeGreaterThan(0) + expect(getHorizontalScrollbarHeight(component)).toBeGreaterThan(0) + expect(verticalScrollbar.style.bottom).toBe(getVerticalScrollbarWidth(component) + 'px') + expect(horizontalScrollbar.style.right).toBe(getHorizontalScrollbarHeight(component) + 'px') + expect(component.refs.scrollbarCorner).toBeDefined() + + setScrollTop(component, 100) + await setScrollLeft(component, 100) + expect(verticalScrollbar.scrollTop).toBe(100) + expect(horizontalScrollbar.scrollLeft).toBe(100) + + verticalScrollbar.scrollTop = 120 + horizontalScrollbar.scrollLeft = 120 + await component.getNextUpdatePromise() + expect(component.getScrollTop()).toBe(120) + expect(component.getScrollLeft()).toBe(120) + + editor.setText('a\n'.repeat(15)) + await component.getNextUpdatePromise() + expect(getVerticalScrollbarWidth(component)).toBeGreaterThan(0) + expect(getHorizontalScrollbarHeight(component)).toBe(0) + expect(verticalScrollbar.style.bottom).toBe('0px') + expect(component.refs.scrollbarCorner).toBeUndefined() + + editor.setText('a'.repeat(100)) + await component.getNextUpdatePromise() + expect(getVerticalScrollbarWidth(component)).toBe(0) + expect(getHorizontalScrollbarHeight(component)).toBeGreaterThan(0) + expect(horizontalScrollbar.style.right).toBe('0px') + expect(component.refs.scrollbarCorner).toBeUndefined() + + editor.setText('') + await component.getNextUpdatePromise() + expect(getVerticalScrollbarWidth(component)).toBe(0) + expect(getHorizontalScrollbarHeight(component)).toBe(0) + expect(component.refs.scrollbarCorner).toBeUndefined() + }) + + it('updates the bottom/right of dummy scrollbars and client height/width measurements when scrollbar styles change', async () => { + const {component, element, editor} = buildComponent({height: 100, width: 100}) + expect(getHorizontalScrollbarHeight(component)).toBeGreaterThan(10) + expect(getVerticalScrollbarWidth(component)).toBeGreaterThan(10) + + const style = document.createElement('style') + style.textContent = '::-webkit-scrollbar { height: 10px; width: 10px; }' + jasmine.attachToDOM(style) + + TextEditor.didUpdateScrollbarStyles() + await component.getNextUpdatePromise() + + expect(getHorizontalScrollbarHeight(component)).toBe(10) + expect(getVerticalScrollbarWidth(component)).toBe(10) + expect(component.refs.horizontalScrollbar.element.style.right).toBe('10px') + expect(component.refs.verticalScrollbar.element.style.bottom).toBe('10px') + expect(component.getScrollContainerClientHeight()).toBe(100 - 10) + expect(component.getScrollContainerClientWidth()).toBe(100 - component.getGutterContainerWidth() - 10) + }) + it('renders cursors within the visible row range', async () => { const {component, element, editor} = buildComponent({height: 40, rowsPerTile: 2}) - component.setScrollTop(100) - await component.getNextUpdatePromise() + await setScrollTop(component, 100) expect(component.getRenderedStartRow()).toBe(4) expect(component.getRenderedEndRow()).toBe(10) @@ -171,9 +230,8 @@ describe('TextEditorComponent', () => { it('places the hidden input element at the location of the last cursor if it is visible', async () => { const {component, element, editor} = buildComponent({height: 60, width: 120, rowsPerTile: 2}) const {hiddenInput} = component.refs - component.setScrollTop(100) - component.setScrollLeft(40) - await component.getNextUpdatePromise() + setScrollTop(component, 100) + await setScrollLeft(component, 40) expect(component.getRenderedStartRow()).toBe(4) expect(component.getRenderedEndRow()).toBe(12) @@ -718,8 +776,7 @@ describe('TextEditorComponent', () => { ) // Don't flash on next update if another flash wasn't requested - component.setScrollTop(100) - await component.getNextUpdatePromise() + await setScrollTop(component, 100) expect(highlights[0].classList.contains('b')).toBe(false) expect(highlights[1].classList.contains('b')).toBe(false) @@ -1184,9 +1241,8 @@ describe('TextEditorComponent', () => { const maxScrollTop = component.getMaxScrollTop() const maxScrollLeft = component.getMaxScrollLeft() - component.setScrollTop(maxScrollTop) - component.setScrollLeft(maxScrollLeft) - await component.getNextUpdatePromise() + setScrollTop(component, maxScrollTop) + await setScrollLeft(component, maxScrollLeft) didDrag({clientX: 199, clientY: 199}) didDrag({clientX: 199, clientY: 199}) @@ -1413,9 +1469,8 @@ describe('TextEditorComponent', () => { const maxScrollTop = component.getMaxScrollTop() const maxScrollLeft = component.getMaxScrollLeft() - component.setScrollTop(maxScrollTop) - component.setScrollLeft(maxScrollLeft) - await component.getNextUpdatePromise() + setScrollTop(component, maxScrollTop) + await setScrollLeft(component, maxScrollLeft) didDrag({clientX: 199, clientY: 199}) didDrag({clientX: 199, clientY: 199}) @@ -1518,6 +1573,28 @@ function textNodesForScreenRow (component, row) { return component.textNodesByScreenLineId.get(screenLine.id) } +function setScrollTop (component, scrollTop) { + component.setScrollTop(scrollTop) + component.scheduleUpdate() + return component.getNextUpdatePromise() +} + +function setScrollLeft (component, scrollTop) { + component.setScrollLeft(scrollTop) + component.scheduleUpdate() + return component.getNextUpdatePromise() +} + +function getHorizontalScrollbarHeight (component) { + const element = component.refs.horizontalScrollbar.element + return element.offsetHeight - element.clientHeight +} + +function getVerticalScrollbarWidth (component) { + const element = component.refs.verticalScrollbar.element + return element.offsetWidth - element.clientWidth +} + function assertDocumentFocused () { if (!document.hasFocus()) { throw new Error('The document needs to be focused to run this test') diff --git a/src/text-editor-component.js b/src/text-editor-component.js index ff199d49146..dc3f7dd66aa 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -55,9 +55,12 @@ class TextEditorComponent { this.horizontalPixelPositionsByScreenLineId = new Map() // Values are maps from column to horiontal pixel positions this.lineNodesByScreenLineId = new Map() this.textNodesByScreenLineId = new Map() + this.didScrollDummyScrollbar = this.didScrollDummyScrollbar.bind(this) this.scrollbarsVisible = true this.refreshScrollbarStyling = false this.pendingAutoscroll = null + this.scrollTopPending = false + this.scrollLeftPending = false this.scrollTop = 0 this.scrollLeft = 0 this.previousScrollWidth = 0 @@ -134,7 +137,8 @@ class TextEditorComponent { etch.updateSync(this) this.currentFrameLineNumberGutterProps = null - + this.scrollTopPending = false + this.scrollLeftPending = false if (this.refreshScrollbarStyling) { this.measureScrollbarDimensions() this.refreshScrollbarStyling = false @@ -529,11 +533,13 @@ class TextEditorComponent { $(DummyScrollbarComponent, { ref: 'verticalScrollbar', orientation: 'vertical', + didScroll: this.didScrollDummyScrollbar, scrollHeight, scrollTop, horizontalScrollbarHeight, forceScrollbarVisible }), $(DummyScrollbarComponent, { ref: 'horizontalScrollbar', orientation: 'horizontal', + didScroll: this.didScrollDummyScrollbar, scrollWidth, scrollLeft, verticalScrollbarWidth, forceScrollbarVisible }) ] @@ -543,6 +549,7 @@ class TextEditorComponent { if (verticalScrollbarWidth > 0 && horizontalScrollbarHeight > 0) { elements.push($.div( { + ref: 'scrollbarCorner', style: { position: 'absolute', height: '20px', @@ -869,6 +876,18 @@ class TextEditorComponent { } } + didScrollDummyScrollbar () { + let scrollTopChanged = false + let scrollLeftChanged = false + if (!this.scrollTopPending) { + scrollTopChanged = this.setScrollTop(this.refs.verticalScrollbar.element.scrollTop) + } + if (!this.scrollLeftPending) { + scrollLeftChanged = this.setScrollLeft(this.refs.horizontalScrollbar.element.scrollLeft) + } + if (scrollTopChanged || scrollLeftChanged) this.updateSync() + } + didUpdateScrollbarStyles () { this.refreshScrollbarStyling = true this.scheduleUpdate() @@ -1197,17 +1216,17 @@ class TextEditorComponent { if (!options || options.reversed !== false) { if (desiredScrollBottom > this.getScrollBottom()) { - return this.setScrollBottom(desiredScrollBottom, true) + this.setScrollBottom(desiredScrollBottom) } if (desiredScrollTop < this.getScrollTop()) { - return this.setScrollTop(desiredScrollTop, true) + this.setScrollTop(desiredScrollTop) } } else { if (desiredScrollTop < this.getScrollTop()) { - return this.setScrollTop(desiredScrollTop, true) + this.setScrollTop(desiredScrollTop) } if (desiredScrollBottom > this.getScrollBottom()) { - return this.setScrollBottom(desiredScrollBottom, true) + this.setScrollBottom(desiredScrollBottom) } } @@ -1226,17 +1245,17 @@ class TextEditorComponent { if (!options || options.reversed !== false) { if (desiredScrollRight > this.getScrollRight()) { - this.setScrollRight(desiredScrollRight, true) + this.setScrollRight(desiredScrollRight) } if (desiredScrollLeft < this.getScrollLeft()) { - this.setScrollLeft(desiredScrollLeft, true) + this.setScrollLeft(desiredScrollLeft) } } else { if (desiredScrollLeft < this.getScrollLeft()) { - this.setScrollLeft(desiredScrollLeft, true) + this.setScrollLeft(desiredScrollLeft) } if (desiredScrollRight > this.getScrollRight()) { - this.setScrollRight(desiredScrollRight, true) + this.setScrollRight(desiredScrollRight) } } } @@ -1691,11 +1710,11 @@ class TextEditorComponent { return this.scrollTop } - setScrollTop (scrollTop, suppressUpdate = false) { + setScrollTop (scrollTop) { scrollTop = Math.round(Math.max(0, Math.min(this.getMaxScrollTop(), scrollTop))) if (scrollTop !== this.scrollTop) { + this.scrollTopPending = true this.scrollTop = scrollTop - if (!suppressUpdate) this.scheduleUpdate() return true } else { return false @@ -1710,8 +1729,8 @@ class TextEditorComponent { return this.getScrollTop() + this.getScrollContainerClientHeight() } - setScrollBottom (scrollBottom, suppressUpdate = false) { - return this.setScrollTop(scrollBottom - this.getScrollContainerClientHeight(), suppressUpdate) + setScrollBottom (scrollBottom) { + return this.setScrollTop(scrollBottom - this.getScrollContainerClientHeight()) } getScrollLeft () { @@ -1719,11 +1738,11 @@ class TextEditorComponent { return this.scrollLeft } - setScrollLeft (scrollLeft, suppressUpdate = false) { + setScrollLeft (scrollLeft) { scrollLeft = Math.round(Math.max(0, Math.min(this.getMaxScrollLeft(), scrollLeft))) if (scrollLeft !== this.scrollLeft) { + this.scrollLeftPending = true this.scrollLeft = scrollLeft - if (!suppressUpdate) this.scheduleUpdate() return true } else { return false @@ -1738,8 +1757,8 @@ class TextEditorComponent { return this.getScrollLeft() + this.getScrollContainerClientWidth() } - setScrollRight (scrollRight, suppressUpdate = false) { - return this.setScrollLeft(scrollRight - this.getScrollContainerClientWidth(), suppressUpdate) + setScrollRight (scrollRight) { + return this.setScrollLeft(scrollRight - this.getScrollContainerClientWidth()) } // Ensure the spatial index is populated with rows that are currently @@ -1807,7 +1826,13 @@ class DummyScrollbarComponent { innerStyle.height = (this.props.scrollHeight || 0) + 'px' } - return $.div({style: outerStyle, scrollTop, scrollLeft}, + return $.div( + { + style: outerStyle, + scrollTop, + scrollLeft, + on: {scroll: this.props.didScroll} + }, $.div({style: innerStyle}) ) } From e6e5420f425bba269b18a923b2c88825689b7bbb Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 21 Mar 2017 10:24:27 -0600 Subject: [PATCH 155/403] Correctly handle overflows caused by scrollbar for the opposite axis --- spec/text-editor-component-spec.js | 25 +++++++++++++++++++++++++ src/text-editor-component.js | 22 ++++++++-------------- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 841008b37db..b1e1a8a25a0 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -163,6 +163,31 @@ describe('TextEditorComponent', () => { expect(getVerticalScrollbarWidth(component)).toBe(0) expect(getHorizontalScrollbarHeight(component)).toBe(0) expect(component.refs.scrollbarCorner).toBeUndefined() + + editor.setText(SAMPLE_TEXT) + await component.getNextUpdatePromise() + + // Does not show scrollbars if the content perfectly fits + element.style.width = component.getGutterContainerWidth() + component.getContentWidth() + 'px' + element.style.height = component.getContentHeight() + 'px' + await component.getNextUpdatePromise() + expect(getVerticalScrollbarWidth(component)).toBe(0) + expect(getHorizontalScrollbarHeight(component)).toBe(0) + + // Shows scrollbars if the only reason we overflow is the presence of the + // scrollbar for the opposite axis. + element.style.width = component.getGutterContainerWidth() + component.getContentWidth() - 1 + 'px' + element.style.height = component.getContentHeight() + component.getHorizontalScrollbarHeight() - 1 + 'px' + await component.getNextUpdatePromise() + expect(getVerticalScrollbarWidth(component)).toBeGreaterThan(0) + expect(getHorizontalScrollbarHeight(component)).toBeGreaterThan(0) + + element.style.width = component.getGutterContainerWidth() + component.getContentWidth() + component.getVerticalScrollbarWidth() - 1 + 'px' + element.style.height = component.getContentHeight() - 1 + 'px' + await component.getNextUpdatePromise() + expect(getVerticalScrollbarWidth(component)).toBeGreaterThan(0) + expect(getHorizontalScrollbarHeight(component)).toBeGreaterThan(0) + }) it('updates the bottom/right of dummy scrollbars and client height/width measurements when scrollbar styles change', async () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index dc3f7dd66aa..b89027d3c40 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1581,7 +1581,10 @@ class TextEditorComponent { isVerticalScrollbarVisible () { return ( this.getContentHeight() > this.getScrollContainerHeight() || - this.isContentMinimallyOverlappingBothScrollbars() + ( + this.getContentWidth() > this.getScrollContainerWidth() && + this.getContentHeight() > (this.getScrollContainerHeight() - this.getHorizontalScrollbarHeight()) + ) ) } @@ -1590,23 +1593,14 @@ class TextEditorComponent { !this.props.model.isSoftWrapped() && ( this.getContentWidth() > this.getScrollContainerWidth() || - this.isContentMinimallyOverlappingBothScrollbars() + ( + this.getContentHeight() > this.getScrollContainerHeight() && + this.getContentWidth() > (this.getScrollContainerWidth() - this.getVerticalScrollbarWidth()) + ) ) ) } - isContentMinimallyOverlappingBothScrollbars () { - const clientHeightWithHorizontalScrollbar = - this.getScrollContainerHeight() - this.getHorizontalScrollbarHeight() - const clientWidthWithVerticalScrollbar = - this.getScrollContainerWidth() - this.getVerticalScrollbarWidth() - - return ( - this.getContentHeight() > clientHeightWithHorizontalScrollbar && - this.getContentWidth() > clientWidthWithVerticalScrollbar - ) - } - getScrollHeight () { if (this.props.model.getScrollPastEnd()) { return this.getContentHeight() + Math.max( From 5f2d4c801b50c617d6477edb93a1da6e64c72cda Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 21 Mar 2017 10:58:40 -0600 Subject: [PATCH 156/403] Handle mousedowns on dummy scrollbars that miss the actual scrollbars Because the dummy scrollbar elements are potentially wider than the real scrollbars rendered by the browser, we need to delegate some mousedown events to the parent component. --- spec/text-editor-component-spec.js | 43 ++++++++++++++++++++++++++++++ src/text-editor-component.js | 16 ++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index b1e1a8a25a0..2c8d6253d20 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1504,6 +1504,49 @@ describe('TextEditorComponent', () => { expect(component.getScrollLeft()).toBe(maxScrollLeft) }) }) + + describe('on the scrollbars', () => { + it('delegates the mousedown events to the parent component unless the mousedown was on the actual scrollbar', () => { + const {component, element, editor} = buildComponent({height: 100, width: 100}) + const verticalScrollbar = component.refs.verticalScrollbar + const horizontalScrollbar = component.refs.horizontalScrollbar + + const leftEdgeOfVerticalScrollbar = verticalScrollbar.element.getBoundingClientRect().right - getVerticalScrollbarWidth(component) + const topEdgeOfHorizontalScrollbar = horizontalScrollbar.element.getBoundingClientRect().bottom - getHorizontalScrollbarHeight(component) + + verticalScrollbar.didMousedown({ + button: 0, + detail: 1, + clientY: clientTopForLine(component, 4), + clientX: leftEdgeOfVerticalScrollbar + }) + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + + verticalScrollbar.didMousedown({ + button: 0, + detail: 1, + clientY: clientTopForLine(component, 4), + clientX: leftEdgeOfVerticalScrollbar - 1 + }) + expect(editor.getCursorScreenPosition()).toEqual([4, 6]) + + horizontalScrollbar.didMousedown({ + button: 0, + detail: 1, + clientY: topEdgeOfHorizontalScrollbar, + clientX: component.refs.content.getBoundingClientRect().left + }) + expect(editor.getCursorScreenPosition()).toEqual([4, 6]) + + horizontalScrollbar.didMousedown({ + button: 0, + detail: 1, + clientY: topEdgeOfHorizontalScrollbar - 1, + clientX: component.refs.content.getBoundingClientRect().left + }) + expect(editor.getCursorScreenPosition()).toEqual([4, 0]) + }) + }) }) }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index b89027d3c40..d9674971789 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -56,6 +56,7 @@ class TextEditorComponent { this.lineNodesByScreenLineId = new Map() this.textNodesByScreenLineId = new Map() this.didScrollDummyScrollbar = this.didScrollDummyScrollbar.bind(this) + this.didMouseDownOnContent = this.didMouseDownOnContent.bind(this) this.scrollbarsVisible = true this.refreshScrollbarStyling = false this.pendingAutoscroll = null @@ -534,12 +535,14 @@ class TextEditorComponent { ref: 'verticalScrollbar', orientation: 'vertical', didScroll: this.didScrollDummyScrollbar, + didMousedown: this.didMouseDownOnContent, scrollHeight, scrollTop, horizontalScrollbarHeight, forceScrollbarVisible }), $(DummyScrollbarComponent, { ref: 'horizontalScrollbar', orientation: 'horizontal', didScroll: this.didScrollDummyScrollbar, + didMousedown: this.didMouseDownOnContent, scrollWidth, scrollLeft, verticalScrollbarWidth, forceScrollbarVisible }) ] @@ -1825,12 +1828,23 @@ class DummyScrollbarComponent { style: outerStyle, scrollTop, scrollLeft, - on: {scroll: this.props.didScroll} + on: { + scroll: this.props.didScroll, + mousedown: this.didMousedown + } }, $.div({style: innerStyle}) ) } + didMousedown (event) { + let {bottom, right} = this.element.getBoundingClientRect() + const clickedOnScrollbar = (this.props.orientation === 'horizontal') + ? event.clientY >= (bottom - this.getRealScrollbarHeight()) + : event.clientX >= (right - this.getRealScrollbarWidth()) + if (!clickedOnScrollbar) this.props.didMousedown(event) + } + getRealScrollbarWidth () { return this.element.offsetWidth - this.element.clientWidth } From c7dc567e62a01c924bc20b6359a1204b969d04de Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 21 Mar 2017 11:39:16 -0600 Subject: [PATCH 157/403] Only update scrollTop/Left of dummy scrollbar after inner div is updated --- src/text-editor-component.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index d9674971789..bec17d30cbb 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1783,16 +1783,26 @@ class DummyScrollbarComponent { constructor (props) { this.props = props etch.initialize(this) + this.updateScrollPosition() } update (props) { this.props = props etch.updateSync(this) + this.updateScrollPosition() + } + + // Scroll position must be updated after the inner element is updated to + // ensure the element has an adequate scrollHeight/scrollWidth + updateScrollPosition () { + if (this.props.orientation === 'horizontal') { + this.element.scrollLeft = this.props.scrollLeft + } else { + this.element.scrollTop = this.props.scrollTop + } } render () { - let scrollTop = 0 - let scrollLeft = 0 const outerStyle = { position: 'absolute', contain: 'strict', @@ -1800,7 +1810,6 @@ class DummyScrollbarComponent { } const innerStyle = {} if (this.props.orientation === 'horizontal') { - scrollLeft = this.props.scrollLeft || 0 let right = (this.props.verticalScrollbarWidth || 0) outerStyle.bottom = 0 outerStyle.left = 0 @@ -1811,7 +1820,6 @@ class DummyScrollbarComponent { innerStyle.height = '20px' innerStyle.width = (this.props.scrollWidth || 0) + 'px' } else { - scrollTop = this.props.scrollTop || 0 let bottom = (this.props.horizontalScrollbarHeight || 0) outerStyle.right = 0 outerStyle.top = 0 @@ -1826,8 +1834,6 @@ class DummyScrollbarComponent { return $.div( { style: outerStyle, - scrollTop, - scrollLeft, on: { scroll: this.props.didScroll, mousedown: this.didMousedown From b6f71bc64859bec75ec97ce5c0f9705d5cd9f793 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 22 Mar 2017 08:38:07 -0600 Subject: [PATCH 158/403] Render overlay decorations --- spec/text-editor-component-spec.js | 67 +++++++++++++++++ src/text-editor-component.js | 112 +++++++++++++++++++++++++++-- 2 files changed, 172 insertions(+), 7 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 2c8d6253d20..21cabb6ea46 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -875,6 +875,73 @@ describe('TextEditorComponent', () => { }) }) + describe('overlay decorations', () => { + it('renders overlay elements at the specified screen position unless it would overflow the window', async () => { + const {component, element, editor} = buildComponent({width: 200, height: 100, attach: false}) + const fakeWindow = document.createElement('div') + fakeWindow.style.position = 'absolute' + fakeWindow.style.padding = 20 + 'px' + fakeWindow.style.backgroundColor = 'blue' + fakeWindow.appendChild(element) + jasmine.attachToDOM(fakeWindow) + + component.getWindowInnerWidth = () => fakeWindow.getBoundingClientRect().width + component.getWindowInnerHeight = () => fakeWindow.getBoundingClientRect().height + // spyOn(component, 'getWindowInnerWidth').andCallFake(() => fakeWindow.getBoundingClientRect().width) + // spyOn(component, 'getWindowInnerHeight').andCallFake(() => fakeWindow.getBoundingClientRect().height) + await setScrollTop(component, 50) + await setScrollLeft(component, 100) + + const marker = editor.markScreenPosition([4, 25]) + + const overlayElement = document.createElement('div') + overlayElement.style.width = '50px' + overlayElement.style.height = '50px' + overlayElement.style.margin = '3px' + overlayElement.style.backgroundColor = 'red' + + editor.decorateMarker(marker, {type: 'overlay', item: overlayElement}) + await component.getNextUpdatePromise() + + const overlayWrapper = overlayElement.parentElement + expect(overlayWrapper.getBoundingClientRect().top).toBe(clientTopForLine(component, 5)) + expect(overlayWrapper.getBoundingClientRect().left).toBe(clientLeftForCharacter(component, 4, 25)) + + // Updates the horizontal position on scroll + await setScrollLeft(component, 150) + expect(overlayWrapper.getBoundingClientRect().left).toBe(clientLeftForCharacter(component, 4, 25)) + + // Shifts the overlay horizontally to ensure the overlay element does not + // overflow the window + await setScrollLeft(component, 30) + expect(overlayElement.getBoundingClientRect().right).toBe(fakeWindow.getBoundingClientRect().right) + await setScrollLeft(component, 280) + expect(overlayElement.getBoundingClientRect().left).toBe(fakeWindow.getBoundingClientRect().left) + + // Updates the vertical position on scroll + await setScrollTop(component, 60) + expect(overlayWrapper.getBoundingClientRect().top).toBe(clientTopForLine(component, 5)) + + // Flips the overlay vertically to ensure the overlay element does not + // overflow the bottom of the window + setScrollLeft(component, 100) + await setScrollTop(component, 0) + expect(overlayWrapper.getBoundingClientRect().bottom).toBe(clientTopForLine(component, 4)) + + // Flips the overlay vertically on overlay resize if necessary + await setScrollTop(component, 20) + expect(overlayWrapper.getBoundingClientRect().top).toBe(clientTopForLine(component, 5)) + overlayElement.style.height = 60 + 'px' + await component.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() + expect(overlayWrapper.getBoundingClientRect().top).toBe(clientTopForLine(component, 5)) + }) + }) + describe('mouse input', () => { describe('on the lines', () => { it('positions the cursor on single-click', async () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index bec17d30cbb..627b323d8c4 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1,7 +1,7 @@ const etch = require('etch') const {CompositeDisposable} = require('event-kit') const {Point, Range} = require('text-buffer') -const resizeDetector = require('element-resize-detector')({strategy: 'scroll'}) +const ResizeDetector = require('element-resize-detector') const TextEditor = require('./text-editor') const {isPairedCharacter} = require('./text-utils') const $ = etch.dom @@ -47,6 +47,9 @@ class TextEditorComponent { this.virtualNode.domNode = this.element this.refs = {} + this.updateSync = this.updateSync.bind(this) + this.didScrollDummyScrollbar = this.didScrollDummyScrollbar.bind(this) + this.didMouseDownOnContent = this.didMouseDownOnContent.bind(this) this.disposables = new CompositeDisposable() this.updateScheduled = false this.measurements = null @@ -55,8 +58,6 @@ class TextEditorComponent { this.horizontalPixelPositionsByScreenLineId = new Map() // Values are maps from column to horiontal pixel positions this.lineNodesByScreenLineId = new Map() this.textNodesByScreenLineId = new Map() - this.didScrollDummyScrollbar = this.didScrollDummyScrollbar.bind(this) - this.didMouseDownOnContent = this.didMouseDownOnContent.bind(this) this.scrollbarsVisible = true this.refreshScrollbarStyling = false this.pendingAutoscroll = null @@ -73,7 +74,8 @@ class TextEditorComponent { lineNumbers: new Map(), lines: new Map(), highlights: new Map(), - cursors: [] + cursors: [], + overlays: [] } this.decorationsToMeasure = { highlights: new Map(), @@ -81,7 +83,7 @@ class TextEditorComponent { } this.observeModel() - resizeDetector.listenTo(this.element, this.didResize.bind(this)) + getElementResizeDetector().listenTo(this.element, this.didResize.bind(this)) etch.updateSync(this) } @@ -164,7 +166,7 @@ class TextEditorComponent { const style = {} if (!model.getAutoHeight() && !model.getAutoWidth()) { - style.contain = 'strict' + style.contain = 'size' } if (this.measurements) { @@ -210,7 +212,8 @@ class TextEditorComponent { }, this.renderGutterContainer(), this.renderScrollContainer() - ) + ), + this.renderOverlayDecorations() ) } @@ -571,6 +574,12 @@ class TextEditorComponent { } } + renderOverlayDecorations () { + return this.decorationsToRender.overlays.map((overlayProps) => + $(OverlayComponent, Object.assign({didResize: this.updateSync}, overlayProps)) + ) + } + // This is easier to mock getPlatform () { return process.platform @@ -590,6 +599,7 @@ class TextEditorComponent { queryDecorationsToRender () { this.decorationsToRender.lineNumbers.clear() this.decorationsToRender.lines.clear() + this.decorationsToRender.overlays.length = 0 this.decorationsToMeasure.highlights.clear() this.decorationsToMeasure.cursors.length = 0 @@ -626,6 +636,9 @@ class TextEditorComponent { case 'cursor': this.addCursorDecorationToMeasure(marker, screenRange, reversed) break + case 'overlay': + this.addOverlayDecorationToRender(decoration, marker) + break } } } @@ -714,9 +727,24 @@ class TextEditorComponent { this.decorationsToMeasure.cursors.push({screenPosition, columnWidth, isLastCursor}) } + addOverlayDecorationToRender (decoration, marker) { + const {class: className, item, position} = decoration + const element = atom.views.getView(item) + const screenPosition = (position === 'tail') + ? marker.getTailScreenPosition() + : marker.getHeadScreenPosition() + + this.requestHorizontalMeasurement(screenPosition.row, screenPosition.column) + this.decorationsToRender.overlays.push({ + key: element, + className, element, screenPosition + }) + } + updateAbsolutePositionedDecorations () { this.updateHighlightsToRender() this.updateCursorsToRender() + this.updateOverlaysToRender() } updateHighlightsToRender () { @@ -755,6 +783,43 @@ class TextEditorComponent { } } + 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} = decoration + const {row, column} = screenPosition + const computedStyle = window.getComputedStyle(element) + + let wrapperTop = contentClientRect.top + this.pixelTopForRow(row) + this.getLineHeight() + const elementHeight = element.offsetHeight + const elementTop = wrapperTop + parseInt(computedStyle.marginTop) + const elementBottom = elementTop + elementHeight + const flippedElementTop = wrapperTop - this.getLineHeight() - elementHeight - parseInt(computedStyle.marginBottom) + + if (elementBottom > windowInnerHeight && flippedElementTop >= 0) { + wrapperTop -= (elementTop - flippedElementTop) + } + + let wrapperLeft = contentClientRect.left + this.pixelLeftForRowAndColumn(row, column) + const elementLeft = wrapperLeft + parseInt(computedStyle.marginLeft) + const elementRight = elementLeft + element.offsetWidth + if (elementLeft < 0) { + wrapperLeft -= elementLeft + } else if (elementRight > windowInnerWidth) { + wrapperLeft -= (elementRight - windowInnerWidth) + } + + decoration.pixelTop = wrapperTop + decoration.pixelLeft = wrapperLeft + } + } + didAttach () { if (!this.attached) { this.attached = true @@ -1525,6 +1590,14 @@ class TextEditorComponent { return this.element.offsetWidth > 0 || this.element.offsetHeight > 0 } + getWindowInnerHeight () { + return window.innerHeight + } + + getWindowInnerWidth () { + return window.innerWidth + } + getLineHeight () { return this.measurements.lineHeight } @@ -2278,6 +2351,25 @@ class HighlightComponent { } } +class OverlayComponent { + constructor (props) { + this.props = props + this.element = document.createElement('atom-overlay') + this.element.appendChild(this.props.element) + this.element.style.position = 'fixed' + this.element.style.zIndex = 4 + this.element.style.top = (this.props.pixelTop || 0) + 'px' + this.element.style.left = (this.props.pixelLeft || 0) + 'px' + getElementResizeDetector().listenTo(this.element, this.props.didResize) + } + + update (props) { + this.props = props + 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' + } +} + const classNamesByScopeName = new Map() function classNameForScopeName (scopeName) { let classString = classNamesByScopeName.get(scopeName) @@ -2296,6 +2388,12 @@ function clientRectForRange (textNode, startIndex, endIndex) { return rangeForMeasurement.getBoundingClientRect() } +let resizeDetector +function getElementResizeDetector () { + if (resizeDetector == null) resizeDetector = ResizeDetector({strategy: 'scroll'}) + return resizeDetector +} + function arraysEqual(a, b) { if (a.length !== b.length) return false for (let i = 0, length = a.length; i < length; i++) { From 47761a455ef5a92a9122bb75895aa394ce542d16 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 22 Mar 2017 10:31:48 -0600 Subject: [PATCH 159/403] Support class property on overlay decorations --- spec/text-editor-component-spec.js | 13 ++++++++++++- src/text-editor-component.js | 10 ++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 21cabb6ea46..161c843a255 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -900,10 +900,11 @@ describe('TextEditorComponent', () => { overlayElement.style.margin = '3px' overlayElement.style.backgroundColor = 'red' - editor.decorateMarker(marker, {type: 'overlay', item: overlayElement}) + const decoration = editor.decorateMarker(marker, {type: 'overlay', item: overlayElement, class: 'a'}) await component.getNextUpdatePromise() const overlayWrapper = overlayElement.parentElement + expect(overlayWrapper.classList.contains('a')).toBe(true) expect(overlayWrapper.getBoundingClientRect().top).toBe(clientTopForLine(component, 5)) expect(overlayWrapper.getBoundingClientRect().left).toBe(clientLeftForCharacter(component, 4, 25)) @@ -939,6 +940,16 @@ describe('TextEditorComponent', () => { overlayElement.style.height = 80 + 'px' await component.getNextUpdatePromise() expect(overlayWrapper.getBoundingClientRect().top).toBe(clientTopForLine(component, 5)) + + // Can update overlay wrapper class + decoration.setProperties({type: 'overlay', item: overlayElement, class: 'b'}) + await component.getNextUpdatePromise() + expect(overlayWrapper.classList.contains('a')).toBe(false) + expect(overlayWrapper.classList.contains('b')).toBe(true) + + decoration.setProperties({type: 'overlay', item: overlayElement}) + await component.getNextUpdatePromise() + expect(overlayWrapper.classList.contains('b')).toBe(false) }) }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 627b323d8c4..ccaffc0bcb2 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2355,6 +2355,7 @@ class OverlayComponent { constructor (props) { this.props = props this.element = document.createElement('atom-overlay') + if (this.props.className != null) this.element.classList.add(this.props.className) this.element.appendChild(this.props.element) this.element.style.position = 'fixed' this.element.style.zIndex = 4 @@ -2363,10 +2364,15 @@ class OverlayComponent { getElementResizeDetector().listenTo(this.element, this.props.didResize) } - update (props) { - this.props = props + update (newProps) { + const oldProps = this.props + this.props = 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) + } } } From f2e2475c62bb442cecac4b453c75bdc4c7243511 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 22 Mar 2017 12:09:49 -0600 Subject: [PATCH 160/403] Use spies instead of monkey patching --- spec/text-editor-component-spec.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 161c843a255..e1d2e003008 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -885,10 +885,8 @@ describe('TextEditorComponent', () => { fakeWindow.appendChild(element) jasmine.attachToDOM(fakeWindow) - component.getWindowInnerWidth = () => fakeWindow.getBoundingClientRect().width - component.getWindowInnerHeight = () => fakeWindow.getBoundingClientRect().height - // spyOn(component, 'getWindowInnerWidth').andCallFake(() => fakeWindow.getBoundingClientRect().width) - // spyOn(component, 'getWindowInnerHeight').andCallFake(() => fakeWindow.getBoundingClientRect().height) + spyOn(component, 'getWindowInnerWidth').andCallFake(() => fakeWindow.getBoundingClientRect().width) + spyOn(component, 'getWindowInnerHeight').andCallFake(() => fakeWindow.getBoundingClientRect().height) await setScrollTop(component, 50) await setScrollLeft(component, 100) From 5297e7ab1ace181692e7ff16b206acf46481a5cc Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 22 Mar 2017 12:22:25 -0600 Subject: [PATCH 161/403] Add avoidOverflow: false option for overlays --- spec/text-editor-component-spec.js | 31 ++++++++++++++++--- src/text-editor-component.js | 49 +++++++++++++++--------------- 2 files changed, 52 insertions(+), 28 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index e1d2e003008..de20d16e9d6 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -876,17 +876,22 @@ describe('TextEditorComponent', () => { }) describe('overlay decorations', () => { - it('renders overlay elements at the specified screen position unless it would overflow the window', async () => { - const {component, element, editor} = buildComponent({width: 200, height: 100, attach: false}) + function attachFakeWindow (component) { const fakeWindow = document.createElement('div') fakeWindow.style.position = 'absolute' fakeWindow.style.padding = 20 + 'px' fakeWindow.style.backgroundColor = 'blue' - fakeWindow.appendChild(element) + fakeWindow.appendChild(component.element) jasmine.attachToDOM(fakeWindow) - spyOn(component, 'getWindowInnerWidth').andCallFake(() => fakeWindow.getBoundingClientRect().width) spyOn(component, 'getWindowInnerHeight').andCallFake(() => fakeWindow.getBoundingClientRect().height) + return fakeWindow + } + + it('renders overlay elements at the specified screen position unless it would overflow the window', async () => { + const {component, element, editor} = buildComponent({width: 200, height: 100, attach: false}) + const fakeWindow = attachFakeWindow(component) + await setScrollTop(component, 50) await setScrollLeft(component, 100) @@ -949,6 +954,24 @@ describe('TextEditorComponent', () => { await component.getNextUpdatePromise() expect(overlayWrapper.classList.contains('b')).toBe(false) }) + + it('does not attempt to avoid overflowing the window if `avoidOverflow` is false on the decoration', async () => { + const {component, element, editor} = buildComponent({width: 200, height: 100, attach: false}) + const fakeWindow = attachFakeWindow(component) + const overlayElement = document.createElement('div') + overlayElement.style.width = '50px' + overlayElement.style.height = '50px' + overlayElement.style.margin = '3px' + overlayElement.style.backgroundColor = 'red' + const marker = editor.markScreenPosition([4, 25]) + const decoration = editor.decorateMarker(marker, {type: 'overlay', item: overlayElement, avoidOverflow: false}) + await component.getNextUpdatePromise() + + await setScrollLeft(component, 30) + expect(overlayElement.getBoundingClientRect().right).toBeGreaterThan(fakeWindow.getBoundingClientRect().right) + await setScrollLeft(component, 280) + expect(overlayElement.getBoundingClientRect().left).toBeLessThan(fakeWindow.getBoundingClientRect().left) + }) }) describe('mouse input', () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index ccaffc0bcb2..770af9afbd3 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -576,7 +576,10 @@ class TextEditorComponent { renderOverlayDecorations () { return this.decorationsToRender.overlays.map((overlayProps) => - $(OverlayComponent, Object.assign({didResize: this.updateSync}, overlayProps)) + $(OverlayComponent, Object.assign( + {key: overlayProps.element, didResize: this.updateSync}, + overlayProps + )) ) } @@ -728,17 +731,14 @@ class TextEditorComponent { } addOverlayDecorationToRender (decoration, marker) { - const {class: className, item, position} = decoration + const {class: className, item, position, avoidOverflow} = decoration const element = atom.views.getView(item) const screenPosition = (position === 'tail') ? marker.getTailScreenPosition() : marker.getHeadScreenPosition() this.requestHorizontalMeasurement(screenPosition.row, screenPosition.column) - this.decorationsToRender.overlays.push({ - key: element, - className, element, screenPosition - }) + this.decorationsToRender.overlays.push({className, element, avoidOverflow, screenPosition}) } updateAbsolutePositionedDecorations () { @@ -792,27 +792,28 @@ class TextEditorComponent { const contentClientRect = this.refs.content.getBoundingClientRect() for (let i = 0; i < overlayCount; i++) { const decoration = this.decorationsToRender.overlays[i] - const {element, screenPosition} = decoration + const {element, screenPosition, avoidOverflow} = decoration const {row, column} = screenPosition - const computedStyle = window.getComputedStyle(element) - let wrapperTop = contentClientRect.top + this.pixelTopForRow(row) + this.getLineHeight() - const elementHeight = element.offsetHeight - const elementTop = wrapperTop + parseInt(computedStyle.marginTop) - const elementBottom = elementTop + elementHeight - const flippedElementTop = wrapperTop - this.getLineHeight() - elementHeight - parseInt(computedStyle.marginBottom) - - if (elementBottom > windowInnerHeight && flippedElementTop >= 0) { - wrapperTop -= (elementTop - flippedElementTop) - } - let wrapperLeft = contentClientRect.left + this.pixelLeftForRowAndColumn(row, column) - const elementLeft = wrapperLeft + parseInt(computedStyle.marginLeft) - const elementRight = elementLeft + element.offsetWidth - if (elementLeft < 0) { - wrapperLeft -= elementLeft - } else if (elementRight > windowInnerWidth) { - wrapperLeft -= (elementRight - windowInnerWidth) + + if (avoidOverflow !== false) { + const computedStyle = window.getComputedStyle(element) + const elementHeight = element.offsetHeight + const elementTop = wrapperTop + parseInt(computedStyle.marginTop) + const elementBottom = elementTop + elementHeight + const flippedElementTop = wrapperTop - this.getLineHeight() - elementHeight - parseInt(computedStyle.marginBottom) + const elementLeft = wrapperLeft + parseInt(computedStyle.marginLeft) + const elementRight = elementLeft + element.offsetWidth + + if (elementBottom > windowInnerHeight && flippedElementTop >= 0) { + wrapperTop -= (elementTop - flippedElementTop) + } + if (elementLeft < 0) { + wrapperLeft -= elementLeft + } else if (elementRight > windowInnerWidth) { + wrapperLeft -= (elementRight - windowInnerWidth) + } } decoration.pixelTop = wrapperTop From 1676617218978cb10121e33e2fd8c45957d323aa Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 22 Mar 2017 12:31:51 -0600 Subject: [PATCH 162/403] Add static TextEditor.viewForOverlayItem method to avoid using global --- src/initialize-application-window.coffee | 1 + src/initialize-benchmark-window.js | 1 + src/initialize-test-window.coffee | 1 + src/text-editor-component.js | 2 +- src/text-editor.coffee | 2 ++ 5 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/initialize-application-window.coffee b/src/initialize-application-window.coffee index a70ca54b7e7..ccf88cc9fd9 100644 --- a/src/initialize-application-window.coffee +++ b/src/initialize-application-window.coffee @@ -58,6 +58,7 @@ if global.isGeneratingSnapshot clipboard = new Clipboard TextEditor.setClipboard(clipboard) +TextEditor.viewForOverlayItem = (item) -> atom.views.getView(item) global.atom = new AtomEnvironment({ clipboard, diff --git a/src/initialize-benchmark-window.js b/src/initialize-benchmark-window.js index a8f1aafe6c5..2d9e724b274 100644 --- a/src/initialize-benchmark-window.js +++ b/src/initialize-benchmark-window.js @@ -54,6 +54,7 @@ export default async function () { const clipboard = new Clipboard() TextEditor.setClipboard(clipboard) + TextEditor.viewForOverlayItem = (item) -> atom.views.getView(item) const applicationDelegate = new ApplicationDelegate() const environmentParams = { diff --git a/src/initialize-test-window.coffee b/src/initialize-test-window.coffee index e8758637488..a5fdc43d620 100644 --- a/src/initialize-test-window.coffee +++ b/src/initialize-test-window.coffee @@ -70,6 +70,7 @@ module.exports = ({blobStore}) -> clipboard = new Clipboard TextEditor.setClipboard(clipboard) + TextEditor.viewForOverlayItem = (item) -> atom.views.getView(item) testRunner = require(testRunnerPath) legacyTestRunner = require(legacyTestRunnerPath) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 770af9afbd3..0d5cb08118c 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -732,7 +732,7 @@ class TextEditorComponent { addOverlayDecorationToRender (decoration, marker) { const {class: className, item, position, avoidOverflow} = decoration - const element = atom.views.getView(item) + const element = TextEditor.viewForOverlayItem(item) const screenPosition = (position === 'tail') ? marker.getTailScreenPosition() : marker.getHeadScreenPosition() diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 442858434c3..33bfcbf6fa4 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -65,6 +65,8 @@ class TextEditor extends Model TextEditorComponent ?= require './text-editor-component' TextEditorComponent.didUpdateScrollbarStyles() + @viewForOverlayItem: (item) -> item + serializationVersion: 1 buffer: null From 470780341656a7db4079c3cc0216776ecccf679a Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 22 Mar 2017 12:58:51 -0600 Subject: [PATCH 163/403] Use the atom.views scheduler in TextEditorComponent This ensures smooth scheduling interactions with autocomplete-plus overlays so they measure their dimensions at the right time. --- src/atom-environment.coffee | 1 + src/text-editor-component.js | 4 ++++ src/text-editor.coffee | 4 ++++ 3 files changed, 9 insertions(+) diff --git a/src/atom-environment.coffee b/src/atom-environment.coffee index 7167cc36a28..1a6dd6cbe73 100644 --- a/src/atom-environment.coffee +++ b/src/atom-environment.coffee @@ -135,6 +135,7 @@ class AtomEnvironment extends Model @deserializers = new DeserializerManager(this) @deserializeTimings = {} @views = new ViewRegistry(this) + TextEditor.setScheduler(@views) @notifications = new NotificationManager @updateProcessEnv ?= updateProcessEnv # For testing diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 0d5cb08118c..4be22d9b220 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -24,6 +24,10 @@ function scaleMouseDragAutoscrollDelta (delta) { module.exports = class TextEditorComponent { + static setScheduler (scheduler) { + etch.setScheduler(scheduler) + } + static didUpdateScrollbarStyles () { if (this.attachedComponents) { this.attachedComponents.forEach((component) => { diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 33bfcbf6fa4..9c4c0ccd156 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -61,6 +61,10 @@ class TextEditor extends Model @setClipboard: (clipboard) -> @clipboard = clipboard + @setScheduler: (scheduler) -> + TextEditorComponent ?= require './text-editor-component' + TextEditorComponent.setScheduler(scheduler) + @didUpdateScrollbarStyles: -> TextEditorComponent ?= require './text-editor-component' TextEditorComponent.didUpdateScrollbarStyles() From 555273f9974c478724b03c4e6557ea07efcf9721 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 22 Mar 2017 13:44:51 -0600 Subject: [PATCH 164/403] Refactor --- src/text-editor-component.js | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 4be22d9b220..ce6efe21e20 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -122,7 +122,6 @@ class TextEditorComponent { this.horizontalPositionsToMeasure.clear() if (this.pendingAutoscroll) this.autoscrollVertically() this.populateVisibleRowRange() - const longestLineToMeasure = this.checkForNewLongestLine() this.queryScreenLinesToRender() this.queryDecorationsToRender() this.scrollbarsVisible = !this.refreshScrollbarStyling @@ -130,7 +129,7 @@ class TextEditorComponent { etch.updateSync(this) this.measureHorizontalPositions() - if (longestLineToMeasure) this.measureLongestLineWidth(longestLineToMeasure) + this.measureLongestLineWidth() this.updateAbsolutePositionedDecorations() if (this.pendingAutoscroll) { this.autoscrollHorizontally() @@ -587,16 +586,25 @@ class TextEditorComponent { ) } - // This is easier to mock getPlatform () { return process.platform } queryScreenLinesToRender () { - this.renderedScreenLines = this.props.model.displayLayer.getScreenLines( + const {model} = this.props + + this.renderedScreenLines = model.displayLayer.getScreenLines( this.getRenderedStartRow(), this.getRenderedEndRow() ) + + const longestLineRow = model.getApproximateLongestScreenRow() + const longestLine = model.screenLineForScreenRow(longestLineRow) + if (longestLine !== this.previousLongestLine) { + this.longestLineToMeasure = longestLine + this.longestLineToMeasureRow = longestLineRow + this.previousLongestLine = longestLine + } } renderedScreenLineForRow (row) { @@ -1409,24 +1417,14 @@ class TextEditorComponent { this.measurements.horizontalScrollbarHeight = this.refs.horizontalScrollbar.getRealScrollbarHeight() } - checkForNewLongestLine () { - const {model} = this.props - const longestLineRow = model.getApproximateLongestScreenRow() - const longestLine = model.screenLineForScreenRow(longestLineRow) - if (longestLine !== this.previousLongestLine) { - this.longestLineToMeasure = longestLine - this.longestLineToMeasureRow = longestLineRow - this.previousLongestLine = longestLine - return longestLine + measureLongestLineWidth () { + if (this.longestLineToMeasure) { + this.measurements.longestLineWidth = this.lineNodesByScreenLineId.get(this.longestLineToMeasure.id).firstChild.offsetWidth + this.longestLineToMeasureRow = null + this.longestLineToMeasure = null } } - measureLongestLineWidth (screenLine) { - this.measurements.longestLineWidth = this.lineNodesByScreenLineId.get(screenLine.id).firstChild.offsetWidth - this.longestLineToMeasureRow = null - this.longestLineToMeasure = null - } - requestHorizontalMeasurement (row, column) { if (column === 0) return let columns = this.horizontalPositionsToMeasure.get(row) From 251078da10f25fe64506050f8192191cdb4e6b65 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 22 Mar 2017 14:45:32 -0600 Subject: [PATCH 165/403] Factor editor component update into high-level phases --- src/text-editor-component.js | 56 +++++++++++++++++------------------- 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index ce6efe21e20..fb5cc6b977e 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -62,8 +62,8 @@ class TextEditorComponent { this.horizontalPixelPositionsByScreenLineId = new Map() // Values are maps from column to horiontal pixel positions this.lineNodesByScreenLineId = new Map() this.textNodesByScreenLineId = new Map() - this.scrollbarsVisible = true - this.refreshScrollbarStyling = false + this.shouldRenderDummyScrollbars = true + this.refreshedScrollbarStyle = false this.pendingAutoscroll = null this.scrollTopPending = false this.scrollLeftPending = false @@ -112,25 +112,28 @@ class TextEditorComponent { updateSync () { this.updateScheduled = false - if (this.nextUpdatePromise) { - this.resolveNextUpdatePromise() - this.nextUpdatePromise = null - this.resolveNextUpdatePromise = null - } + if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise() + this.updateSyncBeforeMeasuringContent() + this.measureContentDuringUpdateSync() + this.updateSyncAfterMeasuringContent() + } - const wasHorizontalScrollbarVisible = this.isHorizontalScrollbarVisible() + updateSyncBeforeMeasuringContent () { this.horizontalPositionsToMeasure.clear() if (this.pendingAutoscroll) this.autoscrollVertically() this.populateVisibleRowRange() this.queryScreenLinesToRender() this.queryDecorationsToRender() - this.scrollbarsVisible = !this.refreshScrollbarStyling - + this.shouldRenderDummyScrollbars = !this.refreshedScrollbarStyle etch.updateSync(this) + this.shouldRenderDummyScrollbars = true + } + measureContentDuringUpdateSync () { this.measureHorizontalPositions() - this.measureLongestLineWidth() this.updateAbsolutePositionedDecorations() + const wasHorizontalScrollbarVisible = this.isHorizontalScrollbarVisible() + this.measureLongestLineWidth() if (this.pendingAutoscroll) { this.autoscrollHorizontally() if (!wasHorizontalScrollbarVisible && this.isHorizontalScrollbarVisible()) { @@ -138,32 +141,21 @@ class TextEditorComponent { } this.pendingAutoscroll = null } - this.scrollbarsVisible = true + } + updateSyncAfterMeasuringContent () { etch.updateSync(this) this.currentFrameLineNumberGutterProps = null this.scrollTopPending = false this.scrollLeftPending = false - if (this.refreshScrollbarStyling) { + if (this.refreshedScrollbarStyle) { this.measureScrollbarDimensions() - this.refreshScrollbarStyling = false + this.refreshedScrollbarStyle = false etch.updateSync(this) } } - checkIfScrollDimensionsChanged () { - const scrollHeight = this.getScrollHeight() - const scrollWidth = this.getScrollWidth() - if (scrollHeight !== this.previousScrollHeight || scrollWidth !== this.previousScrollWidth) { - this.previousScrollHeight = scrollHeight - this.previousScrollWidth = scrollWidth - return true - } else { - return false - } - } - render () { const {model} = this.props const style = {} @@ -514,7 +506,7 @@ class TextEditorComponent { } renderDummyScrollbars () { - if (this.scrollbarsVisible) { + if (this.shouldRenderDummyScrollbars) { let scrollHeight, scrollTop, horizontalScrollbarHeight, scrollWidth, scrollLeft, verticalScrollbarWidth, forceScrollbarVisible @@ -531,7 +523,7 @@ class TextEditorComponent { this.isVerticalScrollbarVisible() ? this.getVerticalScrollbarWidth() : 0 - forceScrollbarVisible = this.refreshScrollbarStyling + forceScrollbarVisible = this.refreshedScrollbarStyle } else { forceScrollbarVisible = true } @@ -970,7 +962,7 @@ class TextEditorComponent { } didUpdateScrollbarStyles () { - this.refreshScrollbarStyling = true + this.refreshedScrollbarStyle = true this.scheduleUpdate() } @@ -1848,7 +1840,11 @@ class TextEditorComponent { getNextUpdatePromise () { if (!this.nextUpdatePromise) { this.nextUpdatePromise = new Promise((resolve) => { - this.resolveNextUpdatePromise = resolve + this.resolveNextUpdatePromise = () => { + this.nextUpdatePromise = null + this.resolveNextUpdatePromise = null + resolve() + } }) } return this.nextUpdatePromise From 90452836fe63bf47b88a5ee04e449600453c95f5 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 22 Mar 2017 15:08:37 -0600 Subject: [PATCH 166/403] Fix wrong variable name --- spec/text-editor-component-spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index de20d16e9d6..c532f559de8 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1746,8 +1746,8 @@ function setScrollTop (component, scrollTop) { return component.getNextUpdatePromise() } -function setScrollLeft (component, scrollTop) { - component.setScrollLeft(scrollTop) +function setScrollLeft (component, scrollLeft) { + component.setScrollLeft(scrollLeft) component.scheduleUpdate() return component.getNextUpdatePromise() } From fadde63ec41a5f478a9b01944e8964f54624a9f9 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 22 Mar 2017 15:09:16 -0600 Subject: [PATCH 167/403] Integrate properly with Atom scheduler --- src/text-editor-component.js | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index fb5cc6b977e..b85e30f9b4e 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -105,17 +105,28 @@ class TextEditorComponent { } else if (!this.updateScheduled) { this.updateScheduled = true etch.getScheduler().updateDocument(() => { - if (this.updateScheduled) this.updateSync() + if (this.updateScheduled) this.updateSync(true) }) } } - updateSync () { + updateSync (useScheduler = false) { this.updateScheduled = false if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise() + this.updateSyncBeforeMeasuringContent() - this.measureContentDuringUpdateSync() - this.updateSyncAfterMeasuringContent() + if (useScheduler === true) { + const scheduler = etch.getScheduler() + scheduler.readDocument(() => { + this.measureContentDuringUpdateSync() + scheduler.updateDocument(() => { + this.updateSyncAfterMeasuringContent() + }) + }) + } else { + this.measureContentDuringUpdateSync() + this.updateSyncAfterMeasuringContent() + } } updateSyncBeforeMeasuringContent () { From 6fefef0509e8ad48478c2f8ad26a628bb292c54c Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 22 Mar 2017 16:57:03 -0600 Subject: [PATCH 168/403] Only update scrollTop/Left when they change This avoids forcing a reflows in some circumnstances. --- src/text-editor-component.js | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index b85e30f9b4e..f72d901752f 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1866,13 +1866,26 @@ class DummyScrollbarComponent { constructor (props) { this.props = props etch.initialize(this) - this.updateScrollPosition() + if (this.props.orientation === 'horizontal') { + this.element.scrollLeft = this.props.scrollLeft + } else { + this.element.scrollTop = this.props.scrollTop + } } - update (props) { - this.props = props + update (newProps) { + const oldProps = this.props + this.props = newProps etch.updateSync(this) - this.updateScrollPosition() + if (this.props.orientation === 'horizontal') { + if (newProps.scrollLeft !== oldProps.scrollLeft) { + this.element.scrollLeft = this.props.scrollLeft + } + } else { + if (newProps.scrollTop !== oldProps.scrollTop) { + this.element.scrollTop = this.props.scrollTop + } + } } // Scroll position must be updated after the inner element is updated to From 16694a2166e5296a25edf43cecf6c048a59efb6c Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 22 Mar 2017 17:50:34 -0600 Subject: [PATCH 169/403] Start on custom gutters --- spec/text-editor-component-spec.js | 17 +++++++++++ src/text-editor-component.js | 48 +++++++++++++++++++++++++----- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index c532f559de8..7f240f958be 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -974,6 +974,23 @@ describe('TextEditorComponent', () => { }) }) + describe('custom gutter decorations', () => { + it('arranges custom gutters based on their priority', async () => { + const {component, element, editor} = buildComponent() + editor.addGutter({name: 'e', priority: 2}) + editor.addGutter({name: 'a', priority: -2}) + editor.addGutter({name: 'd', priority: 1}) + editor.addGutter({name: 'b', priority: -1}) + editor.addGutter({name: 'c', priority: 0}) + + await component.getNextUpdatePromise() + const gutters = component.refs.gutterContainer.querySelectorAll('.gutter') + expect(Array.from(gutters).map((g) => g.getAttribute('gutter-name'))).toEqual([ + 'a', 'b', 'c', 'line-number', 'd', 'e' + ]) + }) + }) + describe('mouse input', () => { describe('on the lines', () => { it('positions the cursor on single-click', async () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index f72d901752f..76058792902 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -74,12 +74,14 @@ class TextEditorComponent { this.lastKeydown = null this.lastKeydownBeforeKeypress = null this.accentedCharacterMenuIsOpen = false + this.guttersToRender = [] this.decorationsToRender = { lineNumbers: new Map(), lines: new Map(), highlights: new Map(), cursors: [], - overlays: [] + overlays: [], + customGutter: new Map() } this.decorationsToMeasure = { highlights: new Map(), @@ -134,6 +136,7 @@ class TextEditorComponent { if (this.pendingAutoscroll) this.autoscrollVertically() this.populateVisibleRowRange() this.queryScreenLinesToRender() + this.queryGuttersToRender() this.queryDecorationsToRender() this.shouldRenderDummyScrollbars = !this.refreshedScrollbarStyle etch.updateSync(this) @@ -228,10 +231,22 @@ class TextEditorComponent { const innerStyle = { willChange: 'transform', - backgroundColor: 'inherit' + backgroundColor: 'inherit', + display: 'flex' } + + let gutterNodes if (this.measurements) { innerStyle.transform = `translateY(${-this.getScrollTop()}px)` + gutterNodes = this.guttersToRender.map((gutter) => { + if (gutter.name === 'line-number') { + return this.renderLineNumberGutter() + } else { + return this.renderCustomGutter(gutter.name) + } + }) + } else { + gutterNodes = this.renderLineNumberGutter() } return $.div( @@ -244,9 +259,7 @@ class TextEditorComponent { backgroundColor: 'inherit' } }, - $.div({style: innerStyle}, - this.renderLineNumberGutter() - ) + $.div({style: innerStyle}, gutterNodes) ) } @@ -300,7 +313,7 @@ class TextEditorComponent { { ref: 'lineNumberGutter', className: 'gutter line-numbers', - 'gutter-name': 'line-number' + attributes: {'gutter-name': 'line-number'} }, $.div({className: 'line-number'}, '0'.repeat(maxLineNumberDigits), @@ -310,6 +323,19 @@ class TextEditorComponent { } } + renderCustomGutter (gutterName) { + return $.div( + { + className: 'gutter', + attributes: {'gutter-name': gutterName} + }, + $.div({ + className: 'custom-decorations', + style: {height: this.getScrollHeight() + 'px'} + }) + ) + } + renderScrollContainer () { const style = { position: 'absolute', @@ -614,13 +640,19 @@ class TextEditorComponent { return this.renderedScreenLines[row - this.getRenderedStartRow()] } + queryGuttersToRender () { + this.guttersToRender = this.props.model.getGutters() + } + queryDecorationsToRender () { this.decorationsToRender.lineNumbers.clear() this.decorationsToRender.lines.clear() this.decorationsToRender.overlays.length = 0 + this.decorationsToRender.customGutter.clear() this.decorationsToMeasure.highlights.clear() this.decorationsToMeasure.cursors.length = 0 + const decorationsByMarker = this.props.model.decorationManager.decorationPropertiesByMarkerForScreenRowRange( this.getRenderedStartRow(), @@ -1589,6 +1621,8 @@ class TextEditorComponent { this.disposables.add(model.selectionsMarkerLayer.onDidUpdate(scheduleUpdate)) this.disposables.add(model.displayLayer.onDidChangeSync(scheduleUpdate)) this.disposables.add(model.onDidUpdateDecorations(scheduleUpdate)) + this.disposables.add(model.onDidAddGutter(scheduleUpdate)) + this.disposables.add(model.onDidRemoveGutter(scheduleUpdate)) this.disposables.add(model.onDidRequestAutoscroll(this.didRequestAutoscroll.bind(this))) } @@ -2038,7 +2072,7 @@ class LineNumberGutterComponent { return $.div( { className: 'gutter line-numbers', - 'gutter-name': 'line-number', + attributes: {'gutter-name': 'line-number'}, style: { contain: 'strict', overflow: 'hidden', From d8b22fb3bd9218318ebbf01183d47d987e17b664 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 22 Mar 2017 18:21:19 -0600 Subject: [PATCH 170/403] Associate gutters with their elements and support showing/hiding gutters --- spec/text-editor-component-spec.js | 35 ++++++++++++++++++++++ src/gutter-container.coffee | 3 ++ src/gutter.coffee | 2 ++ src/text-editor-component.js | 48 +++++++++++++++++++++--------- src/text-editor.coffee | 3 ++ 5 files changed, 77 insertions(+), 14 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 7f240f958be..5e6fbf61511 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -989,6 +989,41 @@ describe('TextEditorComponent', () => { 'a', 'b', 'c', 'line-number', 'd', 'e' ]) }) + + it('allows the element of custom gutters to be retrieved', async () => { + const {component, element, editor} = buildComponent() + const gutterA = editor.addGutter({name: 'a', priority: -1}) + const gutterB = editor.addGutter({name: 'b', priority: 1}) + await component.getNextUpdatePromise() + + expect(element.contains(gutterA.element)).toBe(true) + expect(element.contains(gutterB.element)).toBe(true) + }) + + it('can show and hide custom gutters', async () => { + const {component, element, editor} = buildComponent() + const gutterA = editor.addGutter({name: 'a', priority: -1}) + const gutterB = editor.addGutter({name: 'b', priority: 1}) + + await component.getNextUpdatePromise() + expect(gutterA.element.style.display).toBe('') + expect(gutterB.element.style.display).toBe('') + + gutterA.hide() + await component.getNextUpdatePromise() + expect(gutterA.element.style.display).toBe('none') + expect(gutterB.element.style.display).toBe('') + + gutterB.hide() + await component.getNextUpdatePromise() + expect(gutterA.element.style.display).toBe('none') + expect(gutterB.element.style.display).toBe('none') + + gutterA.show() + await component.getNextUpdatePromise() + expect(gutterA.element.style.display).toBe('') + expect(gutterB.element.style.display).toBe('none') + }) }) describe('mouse input', () => { diff --git a/src/gutter-container.coffee b/src/gutter-container.coffee index 084e1e1add8..74350835560 100644 --- a/src/gutter-container.coffee +++ b/src/gutter-container.coffee @@ -8,6 +8,9 @@ class GutterContainer @textEditor = textEditor @emitter = new Emitter + scheduleComponentUpdate: -> + @textEditor.scheduleComponentUpdate() + destroy: -> # Create a copy, because `Gutter::destroy` removes the gutter from # GutterContainer's @gutters. diff --git a/src/gutter.coffee b/src/gutter.coffee index 64535efa4d4..1001fdfa2bb 100644 --- a/src/gutter.coffee +++ b/src/gutter.coffee @@ -70,12 +70,14 @@ class Gutter hide: -> if @visible @visible = false + @gutterContainer.scheduleComponentUpdate() @emitter.emit 'did-change-visible', this # Essential: Show the gutter. show: -> if not @visible @visible = true + @gutterContainer.scheduleComponentUpdate() @emitter.emit 'did-change-visible', this # Essential: Determine whether the gutter is visible. diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 76058792902..d5781e8760b 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -242,7 +242,11 @@ class TextEditorComponent { if (gutter.name === 'line-number') { return this.renderLineNumberGutter() } else { - return this.renderCustomGutter(gutter.name) + return $(CustomGutterComponent, { + key: gutter, + gutter: gutter, + height: this.getScrollHeight() + }) } }) } else { @@ -323,19 +327,6 @@ class TextEditorComponent { } } - renderCustomGutter (gutterName) { - return $.div( - { - className: 'gutter', - attributes: {'gutter-name': gutterName} - }, - $.div({ - className: 'custom-decorations', - style: {height: this.getScrollHeight() + 'px'} - }) - ) - } - renderScrollContainer () { const style = { position: 'absolute', @@ -2106,6 +2097,35 @@ class LineNumberGutterComponent { } } +class CustomGutterComponent { + constructor (props) { + this.props = props + etch.initialize(this) + this.props.gutter.element = this.element + } + + update (props) { + this.props = props + etch.updateSync(this) + } + + render () { + return $.div( + { + className: 'gutter', + attributes: {'gutter-name': this.props.gutter.name}, + style: { + display: this.props.gutter.isVisible() ? '' : 'none' + } + }, + $.div({ + className: 'custom-decorations', + style: {height: this.props.height + 'px'} + }) + ) + } +} + class LinesTileComponent { constructor (props) { this.props = props diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 9c4c0ccd156..49fb920e211 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -392,6 +392,9 @@ class TextEditor extends Model else Promise.resolve() + scheduleComponentUpdate: -> + @component?.scheduleUpdate() + serialize: -> tokenizedBufferState = @tokenizedBuffer.serialize() From 1b1cffb32d15370cd3d5b2e4048c6cc92cf52a3f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 23 Mar 2017 07:28:33 -0600 Subject: [PATCH 171/403] :arrow_up: etch to allow arbitrary objects as keys --- package.json | 2 +- spec/text-editor-component-spec.js | 75 +++++++++++--- src/decoration-manager.js | 9 +- src/gutter.coffee | 4 + src/initialize-application-window.coffee | 2 +- src/initialize-benchmark-window.js | 2 +- src/initialize-test-window.coffee | 2 +- src/text-editor-component.js | 119 ++++++++++++++++++++--- src/text-editor.coffee | 2 +- static/text-editor-light.less | 71 ++------------ 10 files changed, 193 insertions(+), 95 deletions(-) diff --git a/package.json b/package.json index b5be71b3074..13139212279 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dedent": "^0.6.0", "devtron": "1.3.0", "element-resize-detector": "^1.1.10", - "etch": "^0.10.0", + "etch": "^0.11.0", "event-kit": "^2.3.0", "find-parent-dir": "^0.3.0", "first-mate": "7.0.4", diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 5e6fbf61511..14f2c14c95b 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -990,39 +990,92 @@ describe('TextEditorComponent', () => { ]) }) - it('allows the element of custom gutters to be retrieved', async () => { + it('allows the element of custom gutters to be retrieved before being rendered in the editor component', async () => { const {component, element, editor} = buildComponent() + const [lineNumberGutter] = editor.getGutters() const gutterA = editor.addGutter({name: 'a', priority: -1}) const gutterB = editor.addGutter({name: 'b', priority: 1}) + + const lineNumberGutterElement = lineNumberGutter.getElement() + const gutterAElement = gutterA.getElement() + const gutterBElement = gutterB.getElement() + await component.getNextUpdatePromise() - expect(element.contains(gutterA.element)).toBe(true) - expect(element.contains(gutterB.element)).toBe(true) + expect(element.contains(lineNumberGutterElement)).toBe(true) + expect(element.contains(gutterAElement)).toBe(true) + expect(element.contains(gutterBElement)).toBe(true) }) it('can show and hide custom gutters', async () => { const {component, element, editor} = buildComponent() const gutterA = editor.addGutter({name: 'a', priority: -1}) const gutterB = editor.addGutter({name: 'b', priority: 1}) + const gutterAElement = gutterA.getElement() + const gutterBElement = gutterB.getElement() await component.getNextUpdatePromise() - expect(gutterA.element.style.display).toBe('') - expect(gutterB.element.style.display).toBe('') + expect(gutterAElement.style.display).toBe('') + expect(gutterBElement.style.display).toBe('') gutterA.hide() await component.getNextUpdatePromise() - expect(gutterA.element.style.display).toBe('none') - expect(gutterB.element.style.display).toBe('') + expect(gutterAElement.style.display).toBe('none') + expect(gutterBElement.style.display).toBe('') gutterB.hide() await component.getNextUpdatePromise() - expect(gutterA.element.style.display).toBe('none') - expect(gutterB.element.style.display).toBe('none') + expect(gutterAElement.style.display).toBe('none') + expect(gutterBElement.style.display).toBe('none') gutterA.show() await component.getNextUpdatePromise() - expect(gutterA.element.style.display).toBe('') - expect(gutterB.element.style.display).toBe('none') + expect(gutterAElement.style.display).toBe('') + expect(gutterBElement.style.display).toBe('none') + }) + + it('renders decorations in custom gutters', async () => { + const {component, element, editor} = buildComponent() + const gutterA = editor.addGutter({name: 'a', priority: -1}) + const gutterB = editor.addGutter({name: 'b', priority: 1}) + const marker1 = editor.markScreenRange([[2, 0], [4, 0]]) + const marker2 = editor.markScreenRange([[6, 0], [7, 0]]) + const marker3 = editor.markScreenRange([[9, 0], [12, 0]]) + const decorationElement1 = document.createElement('div') + const decorationElement2 = document.createElement('div') + + const decoration1 = gutterA.decorateMarker(marker1, {class: 'a'}) + const decoration2 = gutterA.decorateMarker(marker2, {class: 'b', item: decorationElement1}) + const decoration3 = gutterB.decorateMarker(marker3, {item: decorationElement2}) + await component.getNextUpdatePromise() + + let [decorationNode1, decorationNode2] = gutterA.getElement().firstChild.children + const [decorationNode3] = gutterB.getElement().firstChild.children + + expect(decorationNode1.className).toBe('a') + expect(decorationNode1.getBoundingClientRect().top).toBe(clientTopForLine(component, 2)) + expect(decorationNode1.getBoundingClientRect().bottom).toBe(clientTopForLine(component, 5)) + expect(decorationNode1.firstChild).toBeNull() + + expect(decorationNode2.className).toBe('b') + expect(decorationNode2.getBoundingClientRect().top).toBe(clientTopForLine(component, 6)) + expect(decorationNode2.getBoundingClientRect().bottom).toBe(clientTopForLine(component, 8)) + expect(decorationNode2.firstChild).toBe(decorationElement1) + + expect(decorationNode3.className).toBe('') + expect(decorationNode3.getBoundingClientRect().top).toBe(clientTopForLine(component, 9)) + expect(decorationNode3.getBoundingClientRect().bottom).toBe(clientTopForLine(component, 12) + component.getLineHeight()) + expect(decorationNode3.firstChild).toBe(decorationElement2) + + decoration1.setProperties({type: 'gutter', gutterName: 'a', class: 'c', item: decorationElement1}) + decoration2.setProperties({type: 'gutter', gutterName: 'a', item: decorationElement2}) + decoration3.destroy() + await component.getNextUpdatePromise() + expect(decorationNode1.className).toBe('c') + expect(decorationNode1.firstChild).toBe(decorationElement1) + expect(decorationNode2.className).toBe('') + expect(decorationNode2.firstChild).toBe(decorationElement2) + expect(gutterB.getElement().firstChild.children.length).toBe(0) }) }) diff --git a/src/decoration-manager.js b/src/decoration-manager.js index ec8b8b68429..fc3692bce90 100644 --- a/src/decoration-manager.js +++ b/src/decoration-manager.js @@ -106,9 +106,12 @@ class DecorationManager { } if (hasMarkerDecorations) { - this.decorationsByMarker.get(marker).forEach((decoration) => { - decorationPropertiesForMarker.push(decoration.getProperties()) - }) + const decorationsForMarker = this.decorationsByMarker.get(marker) + if (decorationsForMarker) { + decorationsForMarker.forEach((decoration) => { + decorationPropertiesForMarker.push(decoration.getProperties()) + }) + } } } }) diff --git a/src/gutter.coffee b/src/gutter.coffee index 1001fdfa2bb..19792ff12c3 100644 --- a/src/gutter.coffee +++ b/src/gutter.coffee @@ -1,4 +1,5 @@ {Emitter} = require 'event-kit' +CustomGutterComponent = null DefaultPriority = -100 @@ -102,3 +103,6 @@ class Gutter # Returns a {Decoration} object decorateMarker: (marker, options) -> @gutterContainer.addGutterDecoration(this, marker, options) + + getElement: -> + @element ?= document.createElement('div') diff --git a/src/initialize-application-window.coffee b/src/initialize-application-window.coffee index ccf88cc9fd9..25b9901109b 100644 --- a/src/initialize-application-window.coffee +++ b/src/initialize-application-window.coffee @@ -58,7 +58,7 @@ if global.isGeneratingSnapshot clipboard = new Clipboard TextEditor.setClipboard(clipboard) -TextEditor.viewForOverlayItem = (item) -> atom.views.getView(item) +TextEditor.viewForItem = (item) -> atom.views.getView(item) global.atom = new AtomEnvironment({ clipboard, diff --git a/src/initialize-benchmark-window.js b/src/initialize-benchmark-window.js index 2d9e724b274..166319b94d1 100644 --- a/src/initialize-benchmark-window.js +++ b/src/initialize-benchmark-window.js @@ -54,7 +54,7 @@ export default async function () { const clipboard = new Clipboard() TextEditor.setClipboard(clipboard) - TextEditor.viewForOverlayItem = (item) -> atom.views.getView(item) + TextEditor.viewForItem = (item) -> atom.views.getView(item) const applicationDelegate = new ApplicationDelegate() const environmentParams = { diff --git a/src/initialize-test-window.coffee b/src/initialize-test-window.coffee index a5fdc43d620..5ad10670a6b 100644 --- a/src/initialize-test-window.coffee +++ b/src/initialize-test-window.coffee @@ -70,7 +70,7 @@ module.exports = ({blobStore}) -> clipboard = new Clipboard TextEditor.setClipboard(clipboard) - TextEditor.viewForOverlayItem = (item) -> atom.views.getView(item) + TextEditor.viewForItem = (item) -> atom.views.getView(item) testRunner = require(testRunnerPath) legacyTestRunner = require(legacyTestRunnerPath) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index d5781e8760b..91889e7c1ac 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -240,12 +240,15 @@ class TextEditorComponent { innerStyle.transform = `translateY(${-this.getScrollTop()}px)` gutterNodes = this.guttersToRender.map((gutter) => { if (gutter.name === 'line-number') { - return this.renderLineNumberGutter() + return this.renderLineNumberGutter(gutter) } else { return $(CustomGutterComponent, { key: gutter, - gutter: gutter, - height: this.getScrollHeight() + element: gutter.getElement(), + name: gutter.name, + visible: gutter.isVisible(), + height: this.getScrollHeight(), + decorations: this.decorationsToRender.customGutter.get(gutter.name) }) } }) @@ -267,7 +270,7 @@ class TextEditorComponent { ) } - renderLineNumberGutter () { + renderLineNumberGutter (gutter) { const {model} = this.props if (!model.isLineNumberGutterVisible()) return null @@ -302,6 +305,7 @@ class TextEditorComponent { this.currentFrameLineNumberGutterProps = { ref: 'lineNumberGutter', + element: gutter.getElement(), parentComponent: this, height: this.getScrollHeight(), width: this.getLineNumberGutterWidth(), @@ -680,6 +684,9 @@ class TextEditorComponent { case 'overlay': this.addOverlayDecorationToRender(decoration, marker) break + case 'gutter': + this.addCustomGutterDecorationToRender(decoration, screenRange) + break } } } @@ -770,7 +777,7 @@ class TextEditorComponent { addOverlayDecorationToRender (decoration, marker) { const {class: className, item, position, avoidOverflow} = decoration - const element = TextEditor.viewForOverlayItem(item) + const element = TextEditor.viewForItem(item) const screenPosition = (position === 'tail') ? marker.getTailScreenPosition() : marker.getHeadScreenPosition() @@ -779,6 +786,22 @@ class TextEditorComponent { this.decorationsToRender.overlays.push({className, element, avoidOverflow, screenPosition}) } + addCustomGutterDecorationToRender (decoration, screenRange) { + let decorations = this.decorationsToRender.customGutter.get(decoration.gutterName) + if (!decorations) { + decorations = [] + this.decorationsToRender.customGutter.set(decoration.gutterName, decorations) + } + const top = this.pixelTopForRow(screenRange.start.row) + const height = this.pixelTopForRow(screenRange.end.row + 1) - top + + decorations.push({ + className: decoration.class, + element: TextEditor.viewForItem(decoration.item), + top, height + }) + } + updateAbsolutePositionedDecorations () { this.updateHighlightsToRender() this.updateCursorsToRender() @@ -1984,7 +2007,10 @@ class DummyScrollbarComponent { class LineNumberGutterComponent { constructor (props) { this.props = props - etch.initialize(this) + this.element = this.props.element + this.virtualNode = $.div(null) + this.virtualNode.domNode = this.element + etch.updateSync(this) } update (newProps) { @@ -2100,8 +2126,10 @@ class LineNumberGutterComponent { class CustomGutterComponent { constructor (props) { this.props = props - etch.initialize(this) - this.props.gutter.element = this.element + this.element = this.props.element + this.virtualNode = $.div(null) + this.virtualNode.domNode = this.element + etch.updateSync(this) } update (props) { @@ -2109,21 +2137,68 @@ class CustomGutterComponent { etch.updateSync(this) } + destroy () { + etch.destroy(this) + } + render () { return $.div( { className: 'gutter', - attributes: {'gutter-name': this.props.gutter.name}, + attributes: {'gutter-name': this.props.name}, style: { - display: this.props.gutter.isVisible() ? '' : 'none' + display: this.props.visible ? '' : 'none' } }, - $.div({ - className: 'custom-decorations', - style: {height: this.props.height + 'px'} - }) + $.div( + { + className: 'custom-decorations', + style: {height: this.props.height + 'px'} + }, + this.renderDecorations() + ) ) } + + renderDecorations () { + if (!this.props.decorations) return null + + return this.props.decorations.map(({className, element, top, height}) => { + return $(CustomGutterDecorationComponent, { + className, + element, + top, + height + }) + }) + } +} + +class CustomGutterDecorationComponent { + constructor (props) { + this.props = props + this.element = document.createElement('div') + const {top, height, className, element} = this.props + + this.element.style.position = 'absolute' + this.element.style.top = top + 'px' + this.element.style.height = height + 'px' + if (className != null) this.element.className = className + if (element != null) this.element.appendChild(element) + } + + update (newProps) { + const oldProps = this.props + this.props = newProps + + if (newProps.top != oldProps.top) this.element.style.top = newProps.top + 'px' + if (newProps.height != oldProps.height) this.element.style.height = newProps.height + 'px' + if (newProps.className != oldProps.className) this.element.className = newProps.className || '' + if (newProps.element != oldProps.element) { + if (this.element.firstChild) this.element.firstChild.remove() + this.element.appendChild(newProps.element) + } + } } class LinesTileComponent { @@ -2453,6 +2528,22 @@ class OverlayComponent { } } +class ComponentWrapper { + constructor (props) { + this.component = props.component + this.element = this.component.element + this.component.update(props) + } + + update (props) { + this.component.update(props) + } + + destroy () { + this.component.destroy() + } +} + const classNamesByScopeName = new Map() function classNameForScopeName (scopeName) { let classString = classNamesByScopeName.get(scopeName) diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 49fb920e211..2cd30944c68 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -69,7 +69,7 @@ class TextEditor extends Model TextEditorComponent ?= require './text-editor-component' TextEditorComponent.didUpdateScrollbarStyles() - @viewForOverlayItem: (item) -> item + @viewForItem: (item) -> item.element ? item serializationVersion: 1 diff --git a/static/text-editor-light.less b/static/text-editor-light.less index d688db3c0d0..493696acaf2 100644 --- a/static/text-editor-light.less +++ b/static/text-editor-light.less @@ -24,15 +24,15 @@ atom-text-editor { // background-color: inherit; // } - // .gutter { - // overflow: hidden; - // z-index: 0; - // text-align: right; - // cursor: default; - // min-width: 1em; - // box-sizing: border-box; - // background-color: inherit; - // } + .gutter { + overflow: hidden; + z-index: 0; + text-align: right; + cursor: default; + min-width: 1em; + box-sizing: border-box; + background-color: inherit; + } // // .line-numbers { // position: relative; @@ -85,15 +85,6 @@ atom-text-editor { } } - // .scroll-view { - // position: relative; - // z-index: 0; - // overflow: hidden; - // flex: 1; - // min-width: 0; - // min-height: 0; - // } - .highlight { background: none; padding: 0; @@ -105,13 +96,6 @@ atom-text-editor { z-index: -1; } - // .lines { - // min-width: 100%; - // position: relative; - // z-index: 1; - // background-color: inherit; - // } - .line { white-space: pre; @@ -162,43 +146,6 @@ atom-text-editor { .cursors.blink-off .cursor { opacity: 0; } - - .horizontal-scrollbar { - position: absolute; - left: 0; - right: 0; - bottom: 0; - - height: 15px; - overflow-x: auto; - overflow-y: hidden; - z-index: 3; - cursor: default; - - .scrollbar-content { - height: 15px; - } - } - - .vertical-scrollbar { - position: absolute; - top: 0; - right: 0; - bottom: 0; - - width: 15px; - overflow-x: hidden; - overflow-y: auto; - z-index: 3; - cursor: default; - } - - .scrollbar-corner { - position: absolute; - overflow: auto; - bottom: 0; - right: 0; - } } atom-text-editor[mini] { From d5d3cfc5a938e1669d14c08dd05f1e615e2546a0 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 23 Mar 2017 13:43:02 -0600 Subject: [PATCH 172/403] Adjust left position of scroll container when gutter container resizes --- spec/text-editor-component-spec.js | 26 +++++++++++++++ src/text-editor-component.js | 53 +++++++++++++++++++++++++++--- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 14f2c14c95b..d8d80ecec7a 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -990,6 +990,32 @@ describe('TextEditorComponent', () => { ]) }) + it('adjusts the left edge of the scroll container based on changes to the gutter container width', async () => { + const {component, element, editor} = buildComponent() + const {scrollContainer, gutterContainer} = component.refs + + expect(scrollContainer.getBoundingClientRect().left).toBe(gutterContainer.getBoundingClientRect().right) + const gutterA = editor.addGutter({name: 'a'}) + await component.getNextUpdatePromise() + expect(scrollContainer.getBoundingClientRect().left).toBe(gutterContainer.getBoundingClientRect().right) + + const gutterB = editor.addGutter({name: 'b'}) + await component.getNextUpdatePromise() + expect(scrollContainer.getBoundingClientRect().left).toBe(gutterContainer.getBoundingClientRect().right) + + gutterA.getElement().style.width = 100 + 'px' + await component.getNextUpdatePromise() + expect(scrollContainer.getBoundingClientRect().left).toBe(gutterContainer.getBoundingClientRect().right) + + gutterA.destroy() + await component.getNextUpdatePromise() + expect(scrollContainer.getBoundingClientRect().left).toBe(gutterContainer.getBoundingClientRect().right) + + gutterB.destroy() + await component.getNextUpdatePromise() + expect(scrollContainer.getBoundingClientRect().left).toBe(gutterContainer.getBoundingClientRect().right) + }) + it('allows the element of custom gutters to be retrieved before being rendered in the editor component', async () => { const {component, element, editor} = buildComponent() const [lineNumberGutter] = editor.getGutters() diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 91889e7c1ac..29eef8f3ef0 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -74,6 +74,7 @@ class TextEditorComponent { this.lastKeydown = null this.lastKeydownBeforeKeypress = null this.accentedCharacterMenuIsOpen = false + this.remeasureGutterContainer = false this.guttersToRender = [] this.decorationsToRender = { lineNumbers: new Map(), @@ -88,10 +89,13 @@ class TextEditorComponent { cursors: [] } + etch.updateSync(this) + this.observeModel() getElementResizeDetector().listenTo(this.element, this.didResize.bind(this)) - - etch.updateSync(this) + if (this.refs.gutterContainer) { + getElementResizeDetector().listenTo(this.refs.gutterContainer, this.didResizeGutterContainer.bind(this)) + } } update (props) { @@ -146,6 +150,10 @@ class TextEditorComponent { measureContentDuringUpdateSync () { this.measureHorizontalPositions() this.updateAbsolutePositionedDecorations() + if (this.remeasureGutterContainer) { + this.measureGutterDimensions() + this.remeasureGutterContainer = false + } const wasHorizontalScrollbarVisible = this.isHorizontalScrollbarVisible() this.measureLongestLineWidth() if (this.pendingAutoscroll) { @@ -636,7 +644,19 @@ class TextEditorComponent { } queryGuttersToRender () { + const oldGuttersToRender = this.guttersToRender this.guttersToRender = this.props.model.getGutters() + + if (!oldGuttersToRender || oldGuttersToRender.length !== this.guttersToRender.length) { + this.remeasureGutterContainer = true + } else { + for (let i = 0, length = this.guttersToRender.length; i < length; i++) { + if (this.guttersToRender[i] !== oldGuttersToRender[i]) { + this.remeasureGutterContainer = true + break + } + } + } } queryDecorationsToRender () { @@ -1006,6 +1026,13 @@ class TextEditorComponent { } } + didResizeGutterContainer () { + console.log('didResizeGutterContainer'); + if (this.measureGutterDimensions()) { + this.scheduleUpdate() + } + } + didScrollDummyScrollbar () { let scrollTopChanged = false let scrollLeftChanged = false @@ -1436,11 +1463,29 @@ class TextEditorComponent { } measureGutterDimensions () { + let dimensionsChanged = false + + if (this.refs.gutterContainer) { + const gutterContainerWidth = this.refs.gutterContainer.offsetWidth + if (gutterContainerWidth !== this.measurements.gutterContainerWidth) { + dimensionsChanged = true + this.measurements.gutterContainerWidth = gutterContainerWidth + } + } else { + this.measurements.gutterContainerWidth = 0 + } + if (this.refs.lineNumberGutter) { - this.measurements.lineNumberGutterWidth = this.refs.lineNumberGutter.offsetWidth + const lineNumberGutterWidth = this.refs.lineNumberGutter.offsetWidth + if (lineNumberGutterWidth !== this.measurements.lineNumberGutterWidth) { + dimensionsChanged = true + this.measurements.lineNumberGutterWidth = this.refs.lineNumberGutter.offsetWidth + } } else { this.measurements.lineNumberGutterWidth = 0 } + + return dimensionsChanged } measureClientContainerDimensions () { @@ -1763,7 +1808,7 @@ class TextEditorComponent { } getGutterContainerWidth () { - return this.getLineNumberGutterWidth() + return this.measurements.gutterContainerWidth } getLineNumberGutterWidth () { From 4e834da3e3aedd5a65f60d2caaadea0c72d6c9a3 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 24 Mar 2017 17:59:16 -0600 Subject: [PATCH 173/403] WIP: Render gutters before initial measurement The shouldUpdate method is just returning true for now. We probably need to find a new approach to representing line number decorations that's easier to diff, perhaps a sparse array? --- spec/text-editor-component-spec.js | 51 ++--- src/text-editor-component.js | 295 +++++++++++++++-------------- src/text-editor.coffee | 4 +- 3 files changed, 187 insertions(+), 163 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index d8d80ecec7a..2e1b66e16f0 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1,6 +1,4 @@ -/** @babel */ - -import {it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise} from './async-spec-helpers' +const {it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise} = require('./async-spec-helpers') const TextEditorComponent = require('../src/text-editor-component') const TextEditor = require('../src/text-editor') @@ -21,7 +19,7 @@ document.registerElement('text-editor-component-test-element', { }) }) -describe('TextEditorComponent', () => { +fdescribe('TextEditorComponent', () => { beforeEach(() => { jasmine.useRealClock() }) @@ -30,12 +28,12 @@ describe('TextEditorComponent', () => { it('renders lines and line numbers for the visible region', async () => { const {component, element, editor} = buildComponent({rowsPerTile: 3, autoHeight: false}) - expect(element.querySelectorAll('.line-number').length).toBe(13) + expect(element.querySelectorAll('.line-number').length).toBe(13 + 1) // +1 for placeholder line number expect(element.querySelectorAll('.line').length).toBe(13) element.style.height = 4 * component.measurements.lineHeight + 'px' await component.getNextUpdatePromise() - expect(element.querySelectorAll('.line-number').length).toBe(9) + expect(element.querySelectorAll('.line-number').length).toBe(9 + 1) // +1 for placeholder line number expect(element.querySelectorAll('.line').length).toBe(9) await setScrollTop(component, 5 * component.getLineHeight()) @@ -43,7 +41,7 @@ describe('TextEditorComponent', () => { // After scrolling down beyond > 3 rows, the order of line numbers and lines // in the DOM is a bit weird because the first tile is recycled to the bottom // when it is scrolled out of view - expect(Array.from(element.querySelectorAll('.line-number')).map(element => element.textContent.trim())).toEqual([ + expect(Array.from(element.querySelectorAll('.line-number')).slice(1).map(element => element.textContent.trim())).toEqual([ '10', '11', '12', '4', '5', '6', '7', '8', '9' ]) expect(Array.from(element.querySelectorAll('.line')).map(element => element.textContent)).toEqual([ @@ -59,7 +57,7 @@ describe('TextEditorComponent', () => { ]) await setScrollTop(component, 2.5 * component.getLineHeight()) - expect(Array.from(element.querySelectorAll('.line-number')).map(element => element.textContent.trim())).toEqual([ + expect(Array.from(element.querySelectorAll('.line-number')).slice(1).map(element => element.textContent.trim())).toEqual([ '1', '2', '3', '4', '5', '6', '7', '8', '9' ]) expect(Array.from(element.querySelectorAll('.line')).map(element => element.textContent)).toEqual([ @@ -107,18 +105,19 @@ describe('TextEditorComponent', () => { expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() + 1) }) - it('gives the line number gutter an explicit width and height so its layout can be strictly contained', () => { + it('gives the line number tiles an explicit width and height so their layout can be strictly contained', async () => { const {component, element, editor} = buildComponent({rowsPerTile: 3}) - const gutterElement = element.querySelector('.gutter.line-numbers') - expect(gutterElement.style.width).toBe(element.querySelector('.line-number').offsetWidth + 'px') - expect(gutterElement.style.height).toBe(editor.getScreenLineCount() * component.measurements.lineHeight + 'px') - expect(gutterElement.style.contain).toBe('strict') + const gutterElement = component.refs.lineNumberGutter.element + for (const child of gutterElement.children) { + expect(child.offsetWidth).toBe(gutterElement.offsetWidth) + } - // Tile nodes also have explicit width and height assignment - expect(gutterElement.firstChild.style.width).toBe(element.querySelector('.line-number').offsetWidth + 'px') - expect(gutterElement.firstChild.style.height).toBe(3 * component.measurements.lineHeight + 'px') - expect(gutterElement.firstChild.style.contain).toBe('strict') + editor.setText('\n'.repeat(99)) + await component.getNextUpdatePromise() + for (const child of gutterElement.children) { + expect(child.offsetWidth).toBe(gutterElement.offsetWidth) + } }) it('renders dummy vertical and horizontal scrollbars when content overflows', async () => { @@ -994,26 +993,30 @@ describe('TextEditorComponent', () => { const {component, element, editor} = buildComponent() const {scrollContainer, gutterContainer} = component.refs - expect(scrollContainer.getBoundingClientRect().left).toBe(gutterContainer.getBoundingClientRect().right) + function checkScrollContainerLeft () { + expect(scrollContainer.getBoundingClientRect().left).toBe(Math.round(gutterContainer.getBoundingClientRect().right)) + } + + checkScrollContainerLeft() const gutterA = editor.addGutter({name: 'a'}) await component.getNextUpdatePromise() - expect(scrollContainer.getBoundingClientRect().left).toBe(gutterContainer.getBoundingClientRect().right) + checkScrollContainerLeft() const gutterB = editor.addGutter({name: 'b'}) await component.getNextUpdatePromise() - expect(scrollContainer.getBoundingClientRect().left).toBe(gutterContainer.getBoundingClientRect().right) + checkScrollContainerLeft() gutterA.getElement().style.width = 100 + 'px' await component.getNextUpdatePromise() - expect(scrollContainer.getBoundingClientRect().left).toBe(gutterContainer.getBoundingClientRect().right) + checkScrollContainerLeft() gutterA.destroy() await component.getNextUpdatePromise() - expect(scrollContainer.getBoundingClientRect().left).toBe(gutterContainer.getBoundingClientRect().right) + checkScrollContainerLeft() gutterB.destroy() await component.getNextUpdatePromise() - expect(scrollContainer.getBoundingClientRect().left).toBe(gutterContainer.getBoundingClientRect().right) + checkScrollContainerLeft() }) it('allows the element of custom gutters to be retrieved before being rendered in the editor component', async () => { @@ -1858,7 +1861,7 @@ function lineNumberNodeForScreenRow (component, row) { const gutterElement = component.refs.lineNumberGutter.element const tileStartRow = component.tileStartRowForRow(row) const tileIndex = component.tileIndexForTileStartRow(tileStartRow) - return gutterElement.children[tileIndex].children[row - tileStartRow] + return gutterElement.children[tileIndex + 1].children[row - tileStartRow] } function lineNodeForScreenRow (component, row) { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 29eef8f3ef0..a895997a1e5 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -74,8 +74,14 @@ class TextEditorComponent { this.lastKeydown = null this.lastKeydownBeforeKeypress = null this.accentedCharacterMenuIsOpen = false - this.remeasureGutterContainer = false - this.guttersToRender = [] + this.remeasureGutterDimensions = false + this.guttersToRender = [this.props.model.getLineNumberGutter()] + this.lineNumbersToRender = { + maxDigits: 2, + numbers: [], + keys: [], + foldableFlags: [] + } this.decorationsToRender = { lineNumbers: new Map(), lines: new Map(), @@ -89,6 +95,9 @@ class TextEditorComponent { cursors: [] } + this.queryGuttersToRender() + this.queryMaxLineNumberDigits() + etch.updateSync(this) this.observeModel() @@ -140,6 +149,7 @@ class TextEditorComponent { if (this.pendingAutoscroll) this.autoscrollVertically() this.populateVisibleRowRange() this.queryScreenLinesToRender() + this.queryLineNumbersToRender() this.queryGuttersToRender() this.queryDecorationsToRender() this.shouldRenderDummyScrollbars = !this.refreshedScrollbarStyle @@ -150,9 +160,9 @@ class TextEditorComponent { measureContentDuringUpdateSync () { this.measureHorizontalPositions() this.updateAbsolutePositionedDecorations() - if (this.remeasureGutterContainer) { + if (this.remeasureGutterDimensions) { this.measureGutterDimensions() - this.remeasureGutterContainer = false + this.remeasureGutterDimensions = false } const wasHorizontalScrollbarVisible = this.isHorizontalScrollbarVisible() this.measureLongestLineWidth() @@ -243,25 +253,10 @@ class TextEditorComponent { display: 'flex' } - let gutterNodes + let scrollHeight if (this.measurements) { innerStyle.transform = `translateY(${-this.getScrollTop()}px)` - gutterNodes = this.guttersToRender.map((gutter) => { - if (gutter.name === 'line-number') { - return this.renderLineNumberGutter(gutter) - } else { - return $(CustomGutterComponent, { - key: gutter, - element: gutter.getElement(), - name: gutter.name, - visible: gutter.isVisible(), - height: this.getScrollHeight(), - decorations: this.decorationsToRender.customGutter.get(gutter.name) - }) - } - }) - } else { - gutterNodes = this.renderLineNumberGutter() + scrollHeight = this.getScrollHeight() } return $.div( @@ -274,68 +269,52 @@ class TextEditorComponent { backgroundColor: 'inherit' } }, - $.div({style: innerStyle}, gutterNodes) + $.div({style: innerStyle}, + this.guttersToRender.map((gutter) => { + if (gutter.name === 'line-number') { + return this.renderLineNumberGutter(gutter) + } else { + return $(CustomGutterComponent, { + key: gutter, + element: gutter.getElement(), + name: gutter.name, + visible: gutter.isVisible(), + height: scrollHeight, + decorations: this.decorationsToRender.customGutter.get(gutter.name) + }) + } + }) + ) ) } renderLineNumberGutter (gutter) { - const {model} = this.props - - if (!model.isLineNumberGutterVisible()) return null - - if (this.currentFrameLineNumberGutterProps) { - return $(LineNumberGutterComponent, this.currentFrameLineNumberGutterProps) - } - - const maxLineNumberDigits = Math.max(2, model.getLineCount().toString().length) + if (!this.props.model.isLineNumberGutterVisible()) return null if (this.measurements) { - const startRow = this.getRenderedStartRow() - const endRow = this.getRenderedEndRow() - const renderedRowCount = this.getRenderedRowCount() - const bufferRows = new Array(renderedRowCount) - const foldableFlags = new Array(renderedRowCount) - const softWrappedFlags = new Array(renderedRowCount) - const lineNumberDecorations = new Array(renderedRowCount) - - let previousBufferRow = (startRow > 0) ? model.bufferRowForScreenRow(startRow - 1) : -1 - for (let row = startRow; row < endRow; row++) { - const i = row - startRow - const bufferRow = model.bufferRowForScreenRow(row) - bufferRows[i] = bufferRow - softWrappedFlags[i] = bufferRow === previousBufferRow - foldableFlags[i] = model.isFoldableAtBufferRow(bufferRow) - lineNumberDecorations[i] = this.decorationsToRender.lineNumbers.get(row) - previousBufferRow = bufferRow - } - - const rowsPerTile = this.getRowsPerTile() - - this.currentFrameLineNumberGutterProps = { + const {maxDigits, keys, numbers, foldableFlags} = this.lineNumbersToRender + return $(LineNumberGutterComponent, { ref: 'lineNumberGutter', element: gutter.getElement(), parentComponent: this, + startRow: this.getRenderedStartRow(), + endRow: this.getRenderedEndRow(), + rowsPerTile: this.getRowsPerTile(), + maxDigits: maxDigits, + keys: keys, + numbers: numbers, + foldableFlags: foldableFlags, + decorations: this.decorationsToRender.lineNumbers, height: this.getScrollHeight(), width: this.getLineNumberGutterWidth(), lineHeight: this.getLineHeight(), - startRow, endRow, rowsPerTile, maxLineNumberDigits, - bufferRows, lineNumberDecorations, softWrappedFlags, - foldableFlags - } - - return $(LineNumberGutterComponent, this.currentFrameLineNumberGutterProps) + }) } else { - return $.div( - { - ref: 'lineNumberGutter', - className: 'gutter line-numbers', - attributes: {'gutter-name': 'line-number'} - }, - $.div({className: 'line-number'}, - '0'.repeat(maxLineNumberDigits), - $.div({className: 'icon-right'}) - ) - ) + return $(LineNumberGutterComponent, { + ref: 'lineNumberGutter', + element: gutter.getElement(), + maxDigits: this.lineNumbersToRender.maxDigits + }) } } @@ -639,6 +618,51 @@ class TextEditorComponent { } } + queryLineNumbersToRender () { + const {model} = this.props + if (!model.isLineNumberGutterVisible()) return + + this.queryMaxLineNumberDigits() + + const startRow = this.getRenderedStartRow() + const endRow = this.getRenderedEndRow() + const renderedRowCount = this.getRenderedRowCount() + + const {numbers, keys, foldableFlags} = this.lineNumbersToRender + numbers.length = renderedRowCount + keys.length = renderedRowCount + foldableFlags.length = renderedRowCount + + let previousBufferRow = (startRow > 0) ? model.bufferRowForScreenRow(startRow - 1) : -1 + let softWrapCount = 0 + for (let row = startRow; row < endRow; row++) { + const i = row - startRow + const bufferRow = model.bufferRowForScreenRow(row) + if (bufferRow === previousBufferRow) { + numbers[i] = -1 + keys[i] = bufferRow + 1 + '-' + softWrapCount++ + foldableFlags[i] = false + } else { + softWrapCount = 0 + numbers[i] = bufferRow + 1 + keys[i] = bufferRow + 1 + foldableFlags[i] = model.isFoldableAtBufferRow(bufferRow) + } + previousBufferRow = bufferRow + } + } + + queryMaxLineNumberDigits () { + const {model} = this.props + if (model.isLineNumberGutterVisible()) { + const maxDigits = Math.max(2, model.getLineCount().toString().length) + if (maxDigits !== this.lineNumbersToRender.maxDigits) { + this.remeasureGutterDimensions = true + this.lineNumbersToRender.maxDigits = maxDigits + } + } + } + renderedScreenLineForRow (row) { return this.renderedScreenLines[row - this.getRenderedStartRow()] } @@ -648,11 +672,11 @@ class TextEditorComponent { this.guttersToRender = this.props.model.getGutters() if (!oldGuttersToRender || oldGuttersToRender.length !== this.guttersToRender.length) { - this.remeasureGutterContainer = true + this.remeasureGutterDimensions = true } else { for (let i = 0, length = this.guttersToRender.length; i < length; i++) { if (this.guttersToRender[i] !== oldGuttersToRender[i]) { - this.remeasureGutterContainer = true + this.remeasureGutterDimensions = true break } } @@ -1027,7 +1051,6 @@ class TextEditorComponent { } didResizeGutterContainer () { - console.log('didResizeGutterContainer'); if (this.measureGutterDimensions()) { this.scheduleUpdate() } @@ -1476,10 +1499,10 @@ class TextEditorComponent { } if (this.refs.lineNumberGutter) { - const lineNumberGutterWidth = this.refs.lineNumberGutter.offsetWidth + const lineNumberGutterWidth = this.refs.lineNumberGutter.element.offsetWidth if (lineNumberGutterWidth !== this.measurements.lineNumberGutterWidth) { dimensionsChanged = true - this.measurements.lineNumberGutterWidth = this.refs.lineNumberGutter.offsetWidth + this.measurements.lineNumberGutterWidth = lineNumberGutterWidth } } else { this.measurements.lineNumberGutterWidth = 0 @@ -2068,85 +2091,81 @@ class LineNumberGutterComponent { render () { const { parentComponent, height, width, lineHeight, startRow, endRow, rowsPerTile, - maxLineNumberDigits, bufferRows, softWrappedFlags, foldableFlags, - lineNumberDecorations + maxDigits, keys, numbers, foldableFlags, decorations } = this.props - const renderedTileCount = parentComponent.getRenderedTileCount() - const children = new Array(renderedTileCount) - const tileHeight = rowsPerTile * lineHeight + 'px' - const tileWidth = width + 'px' + let children = null - let softWrapCount = 0 - for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow += rowsPerTile) { - const tileEndRow = Math.min(endRow, tileStartRow + rowsPerTile) - const tileChildren = new Array(tileEndRow - tileStartRow) - for (let row = tileStartRow; row < tileEndRow; row++) { - const i = row - startRow - const bufferRow = bufferRows[i] - const softWrapped = softWrappedFlags[i] - const foldable = foldableFlags[i] - let key, lineNumber - let className = 'line-number' - if (softWrapped) { - softWrapCount++ - key = `${bufferRow}-${softWrapCount}` - lineNumber = '•' - } else { - softWrapCount = 0 - key = bufferRow - lineNumber = (bufferRow + 1).toString() + if (numbers) { + const renderedTileCount = parentComponent.getRenderedTileCount() + children = new Array(renderedTileCount) + const tileHeight = rowsPerTile * lineHeight + 'px' + const tileWidth = width + 'px' + + let softWrapCount = 0 + for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow += rowsPerTile) { + const tileEndRow = Math.min(endRow, tileStartRow + rowsPerTile) + const tileChildren = new Array(tileEndRow - tileStartRow) + for (let row = tileStartRow; row < tileEndRow; row++) { + const i = row - startRow + const key = keys[i] + const foldable = foldableFlags[i] + let number = numbers[i] + + let className = 'line-number' if (foldable) className += ' foldable' - } - const lineNumberDecoration = lineNumberDecorations[i] - if (lineNumberDecoration != null) className += ' ' + lineNumberDecoration + const decorationsForRow = decorations.get(row) + if (decorationsForRow) className += ' ' + decorationsForRow - lineNumber = NBSP_CHARACTER.repeat(maxLineNumberDigits - lineNumber.length) + lineNumber + if (number === -1) number = '•' + number = NBSP_CHARACTER.repeat(maxDigits - number.length) + number - tileChildren[row - tileStartRow] = $.div({key, className}, - lineNumber, - $.div({className: 'icon-right'}) - ) - } + tileChildren[row - tileStartRow] = $.div({key, className}, + number, + $.div({className: 'icon-right'}) + ) + } - const tileIndex = parentComponent.tileIndexForTileStartRow(tileStartRow) - const top = tileStartRow * lineHeight + const tileIndex = parentComponent.tileIndexForTileStartRow(tileStartRow) + const top = tileStartRow * lineHeight - children[tileIndex] = $.div({ - key: tileIndex, - on: { - mousedown: this.didMouseDown - }, - style: { - contain: 'strict', - overflow: 'hidden', - position: 'absolute', - height: tileHeight, - width: tileWidth, - willChange: 'transform', - transform: `translateY(${top}px)`, - backgroundColor: 'inherit' - } - }, ...tileChildren) + children[tileIndex] = $.div({ + key: tileIndex, + style: { + contain: 'strict', + overflow: 'hidden', + position: 'absolute', + top: 0, + height: tileHeight, + width: tileWidth, + willChange: 'transform', + transform: `translateY(${top}px)`, + backgroundColor: 'inherit' + } + }, ...tileChildren) + } } return $.div( { className: 'gutter line-numbers', attributes: {'gutter-name': 'line-number'}, - style: { - contain: 'strict', - overflow: 'hidden', - height: height + 'px', - width: tileWidth - } + style: {position: 'relative'}, + on: { + mousedown: this.didMouseDown + }, }, - ...children + $.div({key: 'placeholder', className: 'line-number', style: {visibility: 'hidden'}}, + '0'.repeat(maxDigits), + $.div({className: 'icon-right'}) + ), + children ) } shouldUpdate (newProps) { + return true const oldProps = this.props if (oldProps.height !== newProps.height) return true @@ -2155,11 +2174,11 @@ class LineNumberGutterComponent { if (oldProps.startRow !== newProps.startRow) return true if (oldProps.endRow !== newProps.endRow) return true if (oldProps.rowsPerTile !== newProps.rowsPerTile) return true - if (oldProps.maxLineNumberDigits !== newProps.maxLineNumberDigits) return true - if (!arraysEqual(oldProps.bufferRows, newProps.bufferRows)) return true - if (!arraysEqual(oldProps.softWrappedFlags, newProps.softWrappedFlags)) return true + if (oldProps.maxDigits !== newProps.maxDigits) return true + if (!arraysEqual(oldProps.keys, newProps.keys)) return true + if (!arraysEqual(oldProps.numbers, newProps.numbers)) return true if (!arraysEqual(oldProps.foldableFlags, newProps.foldableFlags)) return true - if (!arraysEqual(oldProps.lineNumberDecorations, newProps.lineNumberDecorations)) return true + if (!arraysEqual(oldProps.decorations, newProps.decorations)) return true return false } diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 2cd30944c68..f2c0ab92f38 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -2671,7 +2671,6 @@ class TextEditor extends Model _.last(@selections) getSelectionAtScreenPosition: (position) -> - debugger if global.debug markers = @selectionsMarkerLayer.findMarkers(containsScreenPosition: position) if markers.length > 0 @cursorsByMarkerId.get(markers[0].id).selection @@ -3405,6 +3404,9 @@ class TextEditor extends Model getGutters: -> @gutterContainer.getGutters() + getLineNumberGutter: -> + @lineNumberGutter + # Essential: Get the gutter with the given name. # # Returns a {Gutter}, or `null` if no gutter exists for the given name. From 61583462cf04162b918ac0bed4f37555d60546be Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 27 Mar 2017 10:23:55 -0600 Subject: [PATCH 174/403] Set the height of the line number gutter explicitly --- spec/text-editor-component-spec.js | 13 ++++++++----- src/text-editor-component.js | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 2e1b66e16f0..4e45918efec 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -108,15 +108,18 @@ fdescribe('TextEditorComponent', () => { it('gives the line number tiles an explicit width and height so their layout can be strictly contained', async () => { const {component, element, editor} = buildComponent({rowsPerTile: 3}) - const gutterElement = component.refs.lineNumberGutter.element - for (const child of gutterElement.children) { - expect(child.offsetWidth).toBe(gutterElement.offsetWidth) + const lineNumberGutterElement = component.refs.lineNumberGutter.element + expect(lineNumberGutterElement.offsetHeight).toBe(component.getScrollHeight()) + + for (const child of lineNumberGutterElement.children) { + expect(child.offsetWidth).toBe(lineNumberGutterElement.offsetWidth) } editor.setText('\n'.repeat(99)) await component.getNextUpdatePromise() - for (const child of gutterElement.children) { - expect(child.offsetWidth).toBe(gutterElement.offsetWidth) + expect(lineNumberGutterElement.offsetHeight).toBe(component.getScrollHeight()) + for (const child of lineNumberGutterElement.children) { + expect(child.offsetWidth).toBe(lineNumberGutterElement.offsetWidth) } }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index a895997a1e5..7437c3225b1 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2151,7 +2151,7 @@ class LineNumberGutterComponent { { className: 'gutter line-numbers', attributes: {'gutter-name': 'line-number'}, - style: {position: 'relative'}, + style: {position: 'relative', height: height + 'px'}, on: { mousedown: this.didMouseDown }, From 2880534ba605420847b5f346337025200353c0e0 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 28 Mar 2017 13:54:37 -0600 Subject: [PATCH 175/403] Store line, line number decorations in arrays and avoid slicing --- src/text-editor-component.js | 53 ++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 7437c3225b1..0f28d8cfb5a 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -83,8 +83,8 @@ class TextEditorComponent { foldableFlags: [] } this.decorationsToRender = { - lineNumbers: new Map(), - lines: new Map(), + lineNumbers: null, + lines: null, highlights: new Map(), cursors: [], overlays: [], @@ -398,10 +398,6 @@ class TextEditorComponent { const tileEndRow = Math.min(endRow, tileStartRow + rowsPerTile) const tileIndex = this.tileIndexForTileStartRow(tileStartRow) - const lineDecorations = new Array(tileEndRow - tileStartRow) - for (let row = tileStartRow; row < tileEndRow; row++) { - lineDecorations[row - tileStartRow] = this.decorationsToRender.lines.get(row) - } const highlightDecorations = this.decorationsToRender.highlights.get(tileStartRow) tileNodes[tileIndex] = $(LinesTileComponent, { @@ -410,8 +406,10 @@ class TextEditorComponent { width: tileWidth, top: this.topPixelPositionForRow(tileStartRow), lineHeight: this.getLineHeight(), - screenLines: this.renderedScreenLines.slice(tileStartRow - startRow, tileEndRow - startRow), - lineDecorations, + renderedStartRow: startRow, + tileStartRow, tileEndRow, + screenLines: this.renderedScreenLines, + lineDecorations: this.decorationsToRender.lines, highlightDecorations, displayLayer, lineNodesByScreenLineId, @@ -684,8 +682,8 @@ class TextEditorComponent { } queryDecorationsToRender () { - this.decorationsToRender.lineNumbers.clear() - this.decorationsToRender.lines.clear() + this.decorationsToRender.lineNumbers = [] + this.decorationsToRender.lines = [] this.decorationsToRender.overlays.length = 0 this.decorationsToRender.customGutter.clear() this.decorationsToMeasure.highlights.clear() @@ -736,7 +734,7 @@ class TextEditorComponent { } addLineDecorationToRender (type, decoration, screenRange, reversed) { - const decorationsByRow = (type === 'line') ? this.decorationsToRender.lines : this.decorationsToRender.lineNumbers + const decorationsToRender = (type === 'line') ? this.decorationsToRender.lines : this.decorationsToRender.lineNumbers let omitLastRow = false if (screenRange.isEmpty()) { @@ -748,25 +746,26 @@ class TextEditorComponent { } } - let startRow = screenRange.start.row - let endRow = screenRange.end.row + const renderedStartRow = this.getRenderedStartRow() + let rangeStartRow = screenRange.start.row + let rangeEndRow = screenRange.end.row if (decoration.onlyHead) { if (reversed) { - endRow = startRow + rangeEndRow = rangeStartRow } else { - startRow = endRow + rangeStartRow = rangeEndRow } } - startRow = Math.max(startRow, this.getRenderedStartRow()) - endRow = Math.min(endRow, this.getRenderedEndRow() - 1) + rangeStartRow = Math.max(rangeStartRow, this.getRenderedStartRow()) + rangeEndRow = Math.min(rangeEndRow, this.getRenderedEndRow() - 1) - for (let row = startRow; row <= endRow; row++) { + for (let row = rangeStartRow; row <= rangeEndRow; row++) { if (omitLastRow && row === screenRange.end.row) break - const currentClassName = decorationsByRow.get(row) + const currentClassName = decorationsToRender[row - renderedStartRow] const newClassName = currentClassName ? currentClassName + ' ' + decoration.class : decoration.class - decorationsByRow.set(row, newClassName) + decorationsToRender[row - renderedStartRow] = newClassName } } @@ -2115,7 +2114,7 @@ class LineNumberGutterComponent { let className = 'line-number' if (foldable) className += ' foldable' - const decorationsForRow = decorations.get(row) + const decorationsForRow = decorations[row - startRow] if (decorationsForRow) className += ' ' + decorationsForRow if (number === -1) number = '•' @@ -2165,7 +2164,6 @@ class LineNumberGutterComponent { } shouldUpdate (newProps) { - return true const oldProps = this.props if (oldProps.height !== newProps.height) return true @@ -2331,21 +2329,22 @@ class LinesTileComponent { renderLines () { const { height, width, top, + renderedStartRow, tileStartRow, tileEndRow, screenLines, lineDecorations, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId, } = this.props - const children = new Array(screenLines.length) - for (let i = 0, length = screenLines.length; i < length; i++) { - const screenLine = screenLines[i] + const children = new Array(tileEndRow - tileStartRow) + for (let row = tileStartRow; row < tileEndRow; row++) { + const screenLine = screenLines[row - renderedStartRow] if (!screenLine) { children.length = i break } - children[i] = $(LineComponent, { + children[row - tileStartRow] = $(LineComponent, { key: screenLine.id, screenLine, - lineDecoration: lineDecorations[i], + lineDecoration: lineDecorations[row - renderedStartRow], displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId From 162020443b141cd5321556550a5b7d15666458a2 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 28 Mar 2017 19:36:25 -0600 Subject: [PATCH 176/403] Cache subtrees to avoid duplicating work within a single frame --- src/text-editor-component.js | 259 ++++++++++++++++++++--------------- 1 file changed, 147 insertions(+), 112 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 0f28d8cfb5a..6949e2415c5 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -95,6 +95,11 @@ class TextEditorComponent { cursors: [] } + this.measuredContent = false + this.gutterContainerVnode = null + this.cursorsVnode = null + this.placeholderTextVnode = null + this.queryGuttersToRender() this.queryMaxLineNumberDigits() @@ -129,17 +134,20 @@ class TextEditorComponent { this.updateScheduled = false if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise() + this.measuredContent = false this.updateSyncBeforeMeasuringContent() if (useScheduler === true) { const scheduler = etch.getScheduler() scheduler.readDocument(() => { this.measureContentDuringUpdateSync() + this.measuredContent = true scheduler.updateDocument(() => { this.updateSyncAfterMeasuringContent() }) }) } else { this.measureContentDuringUpdateSync() + this.measuredContent = true this.updateSyncAfterMeasuringContent() } } @@ -161,7 +169,9 @@ class TextEditorComponent { this.measureHorizontalPositions() this.updateAbsolutePositionedDecorations() if (this.remeasureGutterDimensions) { - this.measureGutterDimensions() + if (this.measureGutterDimensions()) { + this.gutterContainerVnode = null + } this.remeasureGutterDimensions = false } const wasHorizontalScrollbarVisible = this.isHorizontalScrollbarVisible() @@ -247,45 +257,49 @@ class TextEditorComponent { renderGutterContainer () { if (this.props.model.isMini()) return null - const innerStyle = { - willChange: 'transform', - backgroundColor: 'inherit', - display: 'flex' - } + if (!this.measuredContent || !this.gutterContainerVnode) { + const innerStyle = { + willChange: 'transform', + backgroundColor: 'inherit', + display: 'flex' + } - let scrollHeight - if (this.measurements) { - innerStyle.transform = `translateY(${-this.getScrollTop()}px)` - scrollHeight = this.getScrollHeight() - } + let scrollHeight + if (this.measurements) { + innerStyle.transform = `translateY(${-this.getScrollTop()}px)` + scrollHeight = this.getScrollHeight() + } - return $.div( - { - ref: 'gutterContainer', - className: 'gutter-container', - style: { - position: 'relative', - zIndex: 1, - backgroundColor: 'inherit' - } - }, - $.div({style: innerStyle}, - this.guttersToRender.map((gutter) => { - if (gutter.name === 'line-number') { - return this.renderLineNumberGutter(gutter) - } else { - return $(CustomGutterComponent, { - key: gutter, - element: gutter.getElement(), - name: gutter.name, - visible: gutter.isVisible(), - height: scrollHeight, - decorations: this.decorationsToRender.customGutter.get(gutter.name) - }) + return $.div( + { + ref: 'gutterContainer', + className: 'gutter-container', + style: { + position: 'relative', + zIndex: 1, + backgroundColor: 'inherit' } - }) + }, + $.div({style: innerStyle}, + this.guttersToRender.map((gutter) => { + if (gutter.name === 'line-number') { + return this.renderLineNumberGutter(gutter) + } else { + return $(CustomGutterComponent, { + key: gutter, + element: gutter.getElement(), + name: gutter.name, + visible: gutter.isVisible(), + height: scrollHeight, + decorations: this.decorationsToRender.customGutter.get(gutter.name) + }) + } + }) + ) ) - ) + } + + return this.gutterContainerVnode } renderLineNumberGutter (gutter) { @@ -402,6 +416,7 @@ class TextEditorComponent { tileNodes[tileIndex] = $(LinesTileComponent, { key: tileIndex, + measuredContent: this.measuredContent, height: tileHeight, width: tileWidth, top: this.topPixelPositionForRow(tileStartRow), @@ -443,44 +458,51 @@ class TextEditorComponent { } renderCursorsAndInput () { - const cursorHeight = this.getLineHeight() + 'px' + if (this.measuredContent) { + const cursorHeight = this.getLineHeight() + 'px' - const children = [this.renderHiddenInput()] + const children = [this.renderHiddenInput()] - for (let i = 0; i < this.decorationsToRender.cursors.length; i++) { - const {pixelLeft, pixelTop, pixelWidth} = this.decorationsToRender.cursors[i] - children.push($.div({ - className: 'cursor', + for (let i = 0; i < this.decorationsToRender.cursors.length; i++) { + const {pixelLeft, pixelTop, pixelWidth} = this.decorationsToRender.cursors[i] + children.push($.div({ + className: 'cursor', + style: { + height: cursorHeight, + width: pixelWidth + 'px', + transform: `translate(${pixelLeft}px, ${pixelTop}px)` + } + })) + } + + this.cursorsVnode = $.div({ + key: 'cursors', + className: 'cursors', style: { - height: cursorHeight, - width: pixelWidth + 'px', - transform: `translate(${pixelLeft}px, ${pixelTop}px)` + position: 'absolute', + contain: 'strict', + zIndex: 1, + width: this.getScrollWidth() + 'px', + height: this.getScrollHeight() + 'px' } - })) + }, children) } - return $.div({ - key: 'cursors', - className: 'cursors', - style: { - position: 'absolute', - contain: 'strict', - zIndex: 1, - width: this.getScrollWidth() + 'px', - height: this.getScrollHeight() + 'px' - } - }, children) + return this.cursorsVnode } renderPlaceholderText () { - const {model} = this.props - if (model.isEmpty()) { - const placeholderText = model.getPlaceholderText() - if (placeholderText != null) { - return $.div({className: 'placeholder-text'}, placeholderText) + if (!this.measuredContent) { + this.placeholderTextVnode = null + const {model} = this.props + if (model.isEmpty()) { + const placeholderText = model.getPlaceholderText() + if (placeholderText != null) { + this.placeholderTextVnode = $.div({className: 'placeholder-text'}, placeholderText) + } } } - return null + return this.placeholderTextVnode } renderHiddenInput () { @@ -545,7 +567,7 @@ class TextEditorComponent { forceScrollbarVisible = true } - const elements = [ + const dummyScrollbarVnodes = [ $(DummyScrollbarComponent, { ref: 'verticalScrollbar', orientation: 'vertical', @@ -565,7 +587,7 @@ class TextEditorComponent { // If both scrollbars are visible, push a dummy element to force a "corner" // to render where the two scrollbars meet at the lower right if (verticalScrollbarWidth > 0 && horizontalScrollbarHeight > 0) { - elements.push($.div( + dummyScrollbarVnodes.push($.div( { ref: 'scrollbarCorner', style: { @@ -580,7 +602,7 @@ class TextEditorComponent { )) } - return elements + return dummyScrollbarVnodes } else { return null } @@ -2266,11 +2288,16 @@ class CustomGutterDecorationComponent { class LinesTileComponent { constructor (props) { this.props = props + this.linesVnode = null + this.highlightsVnode = null etch.initialize(this) } update (newProps) { if (this.shouldUpdate(newProps)) { + if (newProps.width !== this.props.width) { + this.linesVnode = null + } this.props = newProps etch.updateSync(this) } @@ -2298,67 +2325,75 @@ class LinesTileComponent { } renderHighlights () { - const {top, height, width, lineHeight, highlightDecorations} = this.props - - let children = null - if (highlightDecorations) { - const decorationCount = highlightDecorations.length - children = new Array(decorationCount) - for (let i = 0; i < decorationCount; i++) { - const highlightProps = Object.assign( - {parentTileTop: top, lineHeight}, - highlightDecorations[i] - ) - children[i] = $(HighlightComponent, highlightProps) - highlightDecorations[i].flashRequested = false + const {measuredContent, top, height, width, lineHeight, highlightDecorations} = this.props + + if (measuredContent) { + let children = null + if (highlightDecorations) { + const decorationCount = highlightDecorations.length + children = new Array(decorationCount) + for (let i = 0; i < decorationCount; i++) { + const highlightProps = Object.assign( + {parentTileTop: top, lineHeight}, + highlightDecorations[i] + ) + children[i] = $(HighlightComponent, highlightProps) + highlightDecorations[i].flashRequested = false + } } + + this.highlightsVnode = $.div( + { + style: { + position: 'absolute', + contain: 'strict', + height: height + 'px', + width: width + 'px' + }, + }, children + ) } - return $.div( - { - style: { - position: 'absolute', - contain: 'strict', - height: height + 'px', - width: width + 'px' - }, - }, children - ) + return this.highlightsVnode } renderLines () { const { - height, width, top, + measuredContent, height, width, top, renderedStartRow, tileStartRow, tileEndRow, screenLines, lineDecorations, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId, } = this.props - const children = new Array(tileEndRow - tileStartRow) - for (let row = tileStartRow; row < tileEndRow; row++) { - const screenLine = screenLines[row - renderedStartRow] - if (!screenLine) { - children.length = i - break + if (!measuredContent || !this.linesVnode) { + const children = new Array(tileEndRow - tileStartRow) + for (let row = tileStartRow; row < tileEndRow; row++) { + const screenLine = screenLines[row - renderedStartRow] + if (!screenLine) { + children.length = i + break + } + children[row - tileStartRow] = $(LineComponent, { + key: screenLine.id, + screenLine, + lineDecoration: lineDecorations[row - renderedStartRow], + displayLayer, + lineNodesByScreenLineId, + textNodesByScreenLineId + }) } - children[row - tileStartRow] = $(LineComponent, { - key: screenLine.id, - screenLine, - lineDecoration: lineDecorations[row - renderedStartRow], - displayLayer, - lineNodesByScreenLineId, - textNodesByScreenLineId - }) + + this.linesVnode = $.div({ + style: { + position: 'absolute', + contain: 'strict', + height: height + 'px', + width: width + 'px' + } + }, children) } - return $.div({ - style: { - position: 'absolute', - contain: 'strict', - height: height + 'px', - width: width + 'px' - } - }, children) + return this.linesVnode } shouldUpdate (newProps) { From 2faec0b142f8e4af41e9b3444f05fd72f1143a96 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 29 Mar 2017 10:23:12 -0600 Subject: [PATCH 177/403] Avoid using += with let variables to avoid let compound assigment deopt See https://jsperf.com/let-compound-assignment --- src/text-editor-component.js | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 6949e2415c5..dab6166f2b1 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -216,11 +216,10 @@ class TextEditorComponent { } let attributes = null - let className = 'editor' - if (this.focused) className += ' is-focused' + let className = this.focused ? 'editor is-focused' : 'editor' if (model.isMini()) { attributes = {mini: ''} - className += ' mini' + className = className + ' mini' } return $('atom-text-editor', @@ -408,7 +407,7 @@ class TextEditorComponent { const displayLayer = this.props.model.displayLayer const tileNodes = new Array(this.getRenderedTileCount()) - for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow += rowsPerTile) { + for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow = tileStartRow + rowsPerTile) { const tileEndRow = Math.min(endRow, tileStartRow + rowsPerTile) const tileIndex = this.tileIndexForTileStartRow(tileStartRow) @@ -819,7 +818,7 @@ class TextEditorComponent { this.requestHorizontalMeasurement(screenRangeInTile.start.row, screenRangeInTile.start.column) this.requestHorizontalMeasurement(screenRangeInTile.end.row, screenRangeInTile.end.column) - tileStartRow += rowsPerTile + tileStartRow = tileStartRow + rowsPerTile } } @@ -1339,7 +1338,7 @@ class TextEditorComponent { } autoscrollOnMouseDrag ({clientX, clientY}, verticalOnly = false) { - let {top, bottom, left, right} = this.refs.scrollContainer.getBoundingClientRect() + var {top, bottom, left, right} = this.refs.scrollContainer.getBoundingClientRect() // Using var to avoid deopt on += assignments below top += MOUSE_DRAG_AUTOSCROLL_MARGIN bottom -= MOUSE_DRAG_AUTOSCROLL_MARGIN left += MOUSE_DRAG_AUTOSCROLL_MARGIN @@ -1710,7 +1709,7 @@ class TextEditorComponent { let textNodeStartColumn = 0 for (let i = 0; i < containingTextNodeIndex; i++) { - textNodeStartColumn += textNodes[i].length + textNodeStartColumn = textNodeStartColumn + textNodes[i].length } const column = textNodeStartColumn + characterIndex @@ -2124,7 +2123,7 @@ class LineNumberGutterComponent { const tileWidth = width + 'px' let softWrapCount = 0 - for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow += rowsPerTile) { + for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow = tileStartRow + rowsPerTile) { const tileEndRow = Math.min(endRow, tileStartRow + rowsPerTile) const tileChildren = new Array(tileEndRow - tileStartRow) for (let row = tileStartRow; row < tileEndRow; row++) { @@ -2134,10 +2133,10 @@ class LineNumberGutterComponent { let number = numbers[i] let className = 'line-number' - if (foldable) className += ' foldable' + if (foldable) className = className + ' foldable' const decorationsForRow = decorations[row - startRow] - if (decorationsForRow) className += ' ' + decorationsForRow + if (decorationsForRow) className = className + ' ' + decorationsForRow if (number === -1) number = '•' number = NBSP_CHARACTER.repeat(maxDigits - number.length) + number @@ -2453,7 +2452,7 @@ class LineComponent { openScopeNode = newScopeNode } else { const textNode = document.createTextNode(lineText.substr(startIndex, tagCode)) - startIndex += tagCode + startIndex = startIndex + tagCode openScopeNode.appendChild(textNode) textNodes.push(textNode) } @@ -2494,7 +2493,7 @@ class LineComponent { buildClassName () { const {lineDecoration} = this.props let className = 'line' - if (lineDecoration != null) className += ' ' + lineDecoration + if (lineDecoration != null) className = className + ' ' + lineDecoration return className } } From 4da579ceffc3526bd904556ed607fabb2d62a286 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 29 Mar 2017 10:56:51 -0600 Subject: [PATCH 178/403] Unfocus test --- spec/text-editor-component-spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 4e45918efec..95c2eb4fedf 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -19,7 +19,7 @@ document.registerElement('text-editor-component-test-element', { }) }) -fdescribe('TextEditorComponent', () => { +describe('TextEditorComponent', () => { beforeEach(() => { jasmine.useRealClock() }) From b66a2bafae10903ae8e0e3241aa862c8bbffa11a Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 29 Mar 2017 11:36:06 -0600 Subject: [PATCH 179/403] :arrow_up: etch --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 13139212279..ea3967d36ea 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dedent": "^0.6.0", "devtron": "1.3.0", "element-resize-detector": "^1.1.10", - "etch": "^0.11.0", + "etch": "^0.12.0", "event-kit": "^2.3.0", "find-parent-dir": "^0.3.0", "first-mate": "7.0.4", From 76b834e043623d7250616b3e8d3826981eb0fa15 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 29 Mar 2017 17:33:17 -0600 Subject: [PATCH 180/403] Blink cursors; still needs tests --- src/text-editor-component.js | 50 +++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index dab6166f2b1..5cf739334cf 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -17,6 +17,8 @@ const NBSP_CHARACTER = '\u00a0' const ZERO_WIDTH_NBSP_CHARACTER = '\ufeff' const MOUSE_DRAG_AUTOSCROLL_MARGIN = 40 const MOUSE_WHEEL_SCROLL_SENSITIVITY = 0.8 +const CURSOR_BLINK_RESUME_DELAY = 300 +const CURSOR_BLINK_PERIOD = 800 function scaleMouseDragAutoscrollDelta (delta) { return Math.pow(delta / 3, 3) / 280 @@ -458,10 +460,10 @@ class TextEditorComponent { renderCursorsAndInput () { if (this.measuredContent) { + const className = this.cursorsVisible ? 'cursors' : 'cursors blink-off' const cursorHeight = this.getLineHeight() + 'px' const children = [this.renderHiddenInput()] - for (let i = 0; i < this.decorationsToRender.cursors.length; i++) { const {pixelLeft, pixelTop, pixelWidth} = this.decorationsToRender.cursors[i] children.push($.div({ @@ -476,7 +478,8 @@ class TextEditorComponent { this.cursorsVnode = $.div({ key: 'cursors', - className: 'cursors', + ref: 'cursors', + className, style: { position: 'absolute', contain: 'strict', @@ -1008,6 +1011,7 @@ class TextEditorComponent { if (!this.focused) { this.focused = true + this.startCursorBlinking() this.scheduleUpdate() } @@ -1040,6 +1044,7 @@ class TextEditorComponent { didBlurHiddenInput (event) { if (this.element !== event.relatedTarget && !this.element.contains(event.relatedTarget)) { this.focused = false + this.stopCursorBlinking() this.scheduleUpdate() this.element.dispatchEvent(new FocusEvent(event.type, event)) } @@ -1048,6 +1053,7 @@ class TextEditorComponent { didFocusHiddenInput () { if (!this.focused) { this.focused = true + this.startCursorBlinking() this.scheduleUpdate() } } @@ -1387,6 +1393,44 @@ class TextEditorComponent { }) } + didUpdateSelections () { + this.pauseCursorBlinking() + this.scheduleUpdate() + } + + pauseCursorBlinking () { + this.stopCursorBlinking() + if (this.resumeCursorBlinkingTimeoutHandle) { + window.clearTimeout(this.resumeCursorBlinkingTimeoutHandle) + } + this.resumeCursorBlinkingTimeoutHandle = window.setTimeout(() => { + this.cursorsVisible = false + this.startCursorBlinking() + this.resumeCursorBlinkingTimeoutHandle = null + }, CURSOR_BLINK_RESUME_DELAY) + } + + stopCursorBlinking () { + if (this.cursorsBlinking) { + this.cursorsVisible = true + this.cursorsBlinking = false + window.clearInterval(this.cursorBlinkIntervalHandle) + this.cursorBlinkIntervalHandle = null + this.scheduleUpdate() + } + } + + startCursorBlinking () { + if (!this.cursorsBlinking) { + this.cursorBlinkIntervalHandle = window.setInterval(() => { + this.cursorsVisible = !this.cursorsVisible + this.scheduleUpdate() + }, CURSOR_BLINK_PERIOD / 2) + this.cursorsBlinking = true + this.scheduleUpdate() + } + } + didRequestAutoscroll (autoscroll) { this.pendingAutoscroll = autoscroll this.scheduleUpdate() @@ -1720,11 +1764,11 @@ class TextEditorComponent { const {model} = this.props model.component = this const scheduleUpdate = this.scheduleUpdate.bind(this) - this.disposables.add(model.selectionsMarkerLayer.onDidUpdate(scheduleUpdate)) this.disposables.add(model.displayLayer.onDidChangeSync(scheduleUpdate)) this.disposables.add(model.onDidUpdateDecorations(scheduleUpdate)) this.disposables.add(model.onDidAddGutter(scheduleUpdate)) this.disposables.add(model.onDidRemoveGutter(scheduleUpdate)) + this.disposables.add(model.selectionsMarkerLayer.onDidUpdate(this.didUpdateSelections.bind(this))) this.disposables.add(model.onDidRequestAutoscroll(this.didRequestAutoscroll.bind(this))) } From 0cc19aa66b2d68b3d04dbe26e0fa95c413b43ade Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 29 Mar 2017 17:58:32 -0600 Subject: [PATCH 181/403] Implement a fast path for cursor blink to minimize battery impact --- src/text-editor-component.js | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 5cf739334cf..27e14191f56 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -60,6 +60,8 @@ class TextEditorComponent { this.updateScheduled = false this.measurements = null this.visible = false + this.cursorsBlinking = false + this.nextUpdateOnlyBlinksCursors = null this.horizontalPositionsToMeasure = new Map() // Keys are rows with positions we want to measure, values are arrays of columns to measure this.horizontalPixelPositionsByScreenLineId = new Map() // Values are maps from column to horiontal pixel positions this.lineNodesByScreenLineId = new Map() @@ -119,9 +121,12 @@ class TextEditorComponent { this.scheduleUpdate() } - scheduleUpdate () { + scheduleUpdate (nextUpdateOnlyBlinksCursors = false) { if (!this.visible) return + this.nextUpdateOnlyBlinksCursors = + this.nextUpdateOnlyBlinksCursors !== false && nextUpdateOnlyBlinksCursors + if (this.updatedSynchronously) { this.updateSync() } else if (!this.updateScheduled) { @@ -136,6 +141,13 @@ class TextEditorComponent { this.updateScheduled = false if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise() + const onlyBlinkingCursors = this.nextUpdateOnlyBlinksCursors + this.nextUpdateOnlyBlinksCursors = null + if (onlyBlinkingCursors) { + this.updateCursorBlinkSync() + return + } + this.measuredContent = false this.updateSyncBeforeMeasuringContent() if (useScheduler === true) { @@ -200,6 +212,12 @@ class TextEditorComponent { } } + updateCursorBlinkSync () { + const className = this.getCursorsClassName() + this.refs.cursors.className = className + this.cursorsVnode.props.className = className + } + render () { const {model} = this.props const style = {} @@ -460,7 +478,7 @@ class TextEditorComponent { renderCursorsAndInput () { if (this.measuredContent) { - const className = this.cursorsVisible ? 'cursors' : 'cursors blink-off' + const className = this.getCursorsClassName() const cursorHeight = this.getLineHeight() + 'px' const children = [this.renderHiddenInput()] @@ -493,6 +511,10 @@ class TextEditorComponent { return this.cursorsVnode } + getCursorsClassName () { + return this.cursorsVisible ? 'cursors' : 'cursors blink-off' + } + renderPlaceholderText () { if (!this.measuredContent) { this.placeholderTextVnode = null @@ -1424,10 +1446,10 @@ class TextEditorComponent { if (!this.cursorsBlinking) { this.cursorBlinkIntervalHandle = window.setInterval(() => { this.cursorsVisible = !this.cursorsVisible - this.scheduleUpdate() + this.scheduleUpdate(true) }, CURSOR_BLINK_PERIOD / 2) this.cursorsBlinking = true - this.scheduleUpdate() + this.scheduleUpdate(true) } } From eb22b58756add00ae8f2a00d123e52e8ae2f6c69 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 29 Mar 2017 20:05:42 -0600 Subject: [PATCH 182/403] Add smoke test for cursor blink --- spec/text-editor-component-spec.js | 31 ++++++++++++++++++++++++++++++ src/text-editor-component.js | 9 +++++---- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 95c2eb4fedf..1d717f75381 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -254,6 +254,37 @@ describe('TextEditorComponent', () => { expect(cursorNodes.length).toBe(0) }) + it('blinks cursors when the editor is focused and the cursors are not moving', async () => { + assertDocumentFocused() + + const {component, element, editor} = buildComponent() + editor.addCursorAtScreenPosition([1, 0]) + element.focus() + await component.getNextUpdatePromise() + const [cursor1, cursor2] = element.querySelectorAll('.cursor') + + expect(getComputedStyle(cursor1).opacity).toBe('1') + expect(getComputedStyle(cursor2).opacity).toBe('1') + + await conditionPromise(() => + getComputedStyle(cursor1).opacity === '0' && getComputedStyle(cursor2).opacity === '0' + ) + + await conditionPromise(() => + getComputedStyle(cursor1).opacity === '1' && getComputedStyle(cursor2).opacity === '1' + ) + + await conditionPromise(() => + getComputedStyle(cursor1).opacity === '0' && getComputedStyle(cursor2).opacity === '0' + ) + + editor.moveRight() + await component.getNextUpdatePromise() + + expect(getComputedStyle(cursor1).opacity).toBe('1') + expect(getComputedStyle(cursor2).opacity).toBe('1') + }) + it('places the hidden input element at the location of the last cursor if it is visible', async () => { const {component, element, editor} = buildComponent({height: 60, width: 120, rowsPerTile: 2}) const {hiddenInput} = component.refs diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 27e14191f56..17562e47f3c 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -61,6 +61,7 @@ class TextEditorComponent { this.measurements = null this.visible = false this.cursorsBlinking = false + this.cursorsBlinkedOff = false this.nextUpdateOnlyBlinksCursors = null this.horizontalPositionsToMeasure = new Map() // Keys are rows with positions we want to measure, values are arrays of columns to measure this.horizontalPixelPositionsByScreenLineId = new Map() // Values are maps from column to horiontal pixel positions @@ -512,7 +513,7 @@ class TextEditorComponent { } getCursorsClassName () { - return this.cursorsVisible ? 'cursors' : 'cursors blink-off' + return this.cursorsBlinkedOff ? 'cursors blink-off' : 'cursors' } renderPlaceholderText () { @@ -1426,7 +1427,7 @@ class TextEditorComponent { window.clearTimeout(this.resumeCursorBlinkingTimeoutHandle) } this.resumeCursorBlinkingTimeoutHandle = window.setTimeout(() => { - this.cursorsVisible = false + this.cursorsBlinkedOff = true this.startCursorBlinking() this.resumeCursorBlinkingTimeoutHandle = null }, CURSOR_BLINK_RESUME_DELAY) @@ -1434,7 +1435,7 @@ class TextEditorComponent { stopCursorBlinking () { if (this.cursorsBlinking) { - this.cursorsVisible = true + this.cursorsBlinkedOff = false this.cursorsBlinking = false window.clearInterval(this.cursorBlinkIntervalHandle) this.cursorBlinkIntervalHandle = null @@ -1445,7 +1446,7 @@ class TextEditorComponent { startCursorBlinking () { if (!this.cursorsBlinking) { this.cursorBlinkIntervalHandle = window.setInterval(() => { - this.cursorsVisible = !this.cursorsVisible + this.cursorsBlinkedOff = !this.cursorsBlinkedOff this.scheduleUpdate(true) }, CURSOR_BLINK_PERIOD / 2) this.cursorsBlinking = true From 3b7112889a4acdcdc3550b2f4109fea07f0bac6d Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 29 Mar 2017 20:16:04 -0600 Subject: [PATCH 183/403] Correctly assign gutter container vnode --- src/text-editor-component.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 17562e47f3c..e35fb97330a 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -290,7 +290,7 @@ class TextEditorComponent { scrollHeight = this.getScrollHeight() } - return $.div( + this.gutterContainerVnode = $.div( { ref: 'gutterContainer', className: 'gutter-container', From 7da588c3eebdef1352b70fb11b73b3a1cf40aa8e Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 29 Mar 2017 20:18:10 -0600 Subject: [PATCH 184/403] Ensure nextUpdateOnlyBlinksCursor argument is `true`, not just truthy We pass the bound scheduleUpdate method as an event handler to a variety of subscription methods, some of which supply arguments. --- src/text-editor-component.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index e35fb97330a..9dc738d081e 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -126,7 +126,7 @@ class TextEditorComponent { if (!this.visible) return this.nextUpdateOnlyBlinksCursors = - this.nextUpdateOnlyBlinksCursors !== false && nextUpdateOnlyBlinksCursors + this.nextUpdateOnlyBlinksCursors !== false && nextUpdateOnlyBlinksCursors === true if (this.updatedSynchronously) { this.updateSync() From acf996fc14cc8f83fa66359f9fa14aebb1ed4862 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 29 Mar 2017 20:22:18 -0600 Subject: [PATCH 185/403] Speed up cursor blink test --- spec/text-editor-component-spec.js | 4 +++- src/text-editor-component.js | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 1d717f75381..5866ea947d3 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -256,9 +256,11 @@ describe('TextEditorComponent', () => { it('blinks cursors when the editor is focused and the cursors are not moving', async () => { assertDocumentFocused() - const {component, element, editor} = buildComponent() + component.props.cursorBlinkPeriod = 40 + component.props.cursorBlinkResumeDelay = 40 editor.addCursorAtScreenPosition([1, 0]) + element.focus() await component.getNextUpdatePromise() const [cursor1, cursor2] = element.querySelectorAll('.cursor') diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 9dc738d081e..fd23c72eaf0 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1430,7 +1430,7 @@ class TextEditorComponent { this.cursorsBlinkedOff = true this.startCursorBlinking() this.resumeCursorBlinkingTimeoutHandle = null - }, CURSOR_BLINK_RESUME_DELAY) + }, (this.props.cursorBlinkResumeDelay || CURSOR_BLINK_RESUME_DELAY)) } stopCursorBlinking () { @@ -1448,7 +1448,7 @@ class TextEditorComponent { this.cursorBlinkIntervalHandle = window.setInterval(() => { this.cursorsBlinkedOff = !this.cursorsBlinkedOff this.scheduleUpdate(true) - }, CURSOR_BLINK_PERIOD / 2) + }, (this.props.cursorBlinkPeriod || CURSOR_BLINK_PERIOD) / 2) this.cursorsBlinking = true this.scheduleUpdate(true) } From 8652222b229fda90301e8b3fc240a872405781e7 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 29 Mar 2017 20:39:51 -0600 Subject: [PATCH 186/403] Add setInputEnabled and don't handle textInput if it is disabled --- src/text-editor-component.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index fd23c72eaf0..c30f668a983 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1123,6 +1123,8 @@ class TextEditorComponent { } didTextInput (event) { + if (!this.isInputEnabled()) return + event.stopPropagation() // WARNING: If we call preventDefault on the input of a space character, @@ -1131,9 +1133,6 @@ class TextEditorComponent { // to test. if (event.data !== ' ') event.preventDefault() - // TODO: Deal with disabled input - // if (!this.isInputEnabled()) return - if (this.compositionCheckpoint) { this.props.model.revertToCheckpoint(this.compositionCheckpoint) this.compositionCheckpoint = null @@ -2063,6 +2062,14 @@ class TextEditorComponent { } return this.nextUpdatePromise } + + setInputEnabled (inputEnabled) { + this.props.inputEnabled = inputEnabled + } + + isInputEnabled (inputEnabled) { + return this.props.inputEnabled != null ? this.props.inputEnabled : true + } } class DummyScrollbarComponent { From eb588d4c7c328488ff2fb9832ef1ac2810325b38 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 29 Mar 2017 20:51:24 -0600 Subject: [PATCH 187/403] Test and fix the `center` option to autoscroll --- spec/text-editor-component-spec.js | 12 ++++++++++++ src/text-editor-component.js | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 5866ea947d3..d18ba521389 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -523,6 +523,18 @@ describe('TextEditorComponent', () => { expect(component.getScrollBottom()).toBe((6 + 1 + scrollMarginInLines) * component.measurements.lineHeight) }) + it('autoscrolls the given range to the center of the screen if the `center` option is true', async () => { + const {component, editor} = buildComponent({height: 50}) + expect(component.getLastVisibleRow()).toBe(3) + + editor.scrollToScreenRange([[4, 0], [6, 0]], {center: true}) + await component.getNextUpdatePromise() + + const actualScrollCenter = (component.getScrollTop() + component.getScrollBottom()) / 2 + const expectedScrollCenter = Math.round((4 + 7) / 2 * component.getLineHeight()) + expect(actualScrollCenter).toBe(expectedScrollCenter) + }) + it('automatically scrolls horizontally when the requested range is within the horizontal scroll margin of the right edge of the gutter or right edge of the scroll container', async () => { const {component, element, editor} = buildComponent() const {scrollContainer} = component.refs diff --git a/src/text-editor-component.js b/src/text-editor-component.js index c30f668a983..a49e7fd30e4 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1472,8 +1472,8 @@ class TextEditorComponent { if (options && options.center) { const desiredScrollCenter = (screenRangeTop + screenRangeBottom) / 2 if (desiredScrollCenter < this.getScrollTop() || desiredScrollCenter > this.getScrollBottom()) { - desiredScrollTop = desiredScrollCenter - this.measurements.clientHeight / 2 - desiredScrollBottom = desiredScrollCenter + this.measurements.clientHeight / 2 + desiredScrollTop = desiredScrollCenter - this.getScrollContainerClientHeight() / 2 + desiredScrollBottom = desiredScrollCenter + this.getScrollContainerClientHeight() / 2 } } else { desiredScrollTop = screenRangeTop - verticalScrollMargin From 171e4e88ca0f0b9970b52850eea13874f0aa3459 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 29 Mar 2017 21:45:44 -0600 Subject: [PATCH 188/403] Cache prefixed scope names --- src/tokenized-buffer-iterator.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/tokenized-buffer-iterator.js b/src/tokenized-buffer-iterator.js index 29d2fdf8694..614cb01a997 100644 --- a/src/tokenized-buffer-iterator.js +++ b/src/tokenized-buffer-iterator.js @@ -1,5 +1,7 @@ const {Point} = require('text-buffer') +const prefixedScopes = new Map() + module.exports = class TokenizedBufferIterator { constructor (tokenizedBuffer) { this.tokenizedBuffer = tokenizedBuffer @@ -166,7 +168,15 @@ module.exports = class TokenizedBufferIterator { scopeForId (id) { const scope = this.tokenizedBuffer.grammar.scopeForId(id) if (scope) { - return `syntax--${scope.replace(/\./g, '.syntax--')}` + let prefixedScope = prefixedScopes.get(scope) + if (prefixedScope) { + return prefixedScope + } else { + prefixedScope = `syntax--${scope.replace(/\./g, '.syntax--')}` + prefixedScopes.set(scope, prefixedScope) + return prefixedScope + } + return } else { return null } From b32b760ee4e9f1460a80a132c1ddb3ad2333edd1 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 30 Mar 2017 12:20:33 -0600 Subject: [PATCH 189/403] WIP: Start on block decorations --- spec/text-editor-component-spec.js | 54 ++++++++++++++++++++++++++- src/text-editor-component.js | 60 +++++++++++++++++++++++++++--- 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index d18ba521389..2a820247901 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1156,6 +1156,52 @@ describe('TextEditorComponent', () => { }) }) + describe('block decorations', () => { + ffit('renders visible and yet-to-be-measured block decorations, inserting them between the appropriate lines and refreshing them as needed', async () => { + const editor = buildEditor() + const {item: item1, decoration: decoration1} = createBlockDecorationAtScreenRow(editor, 0, {height: 80, position: 'before'}) + const {item: item2, decoration: decoration2} = createBlockDecorationAtScreenRow(editor, 2, {height: 40, margin: 12, position: 'before'}) + const {item: item3, decoration: decoration3} = createBlockDecorationAtScreenRow(editor, 4, {height: 100, position: 'before'}) + const {item: item4, decoration: decoration4} = createBlockDecorationAtScreenRow(editor, 7, {height: 120, position: 'before'}) + const {item: item5, decoration: decoration5} = createBlockDecorationAtScreenRow(editor, 7, {height: 42, position: 'after'}) + const {item: item6, decoration: decoration6} = createBlockDecorationAtScreenRow(editor, 12, {height: 22, position: 'after'}) + + const {component, element} = buildComponent({editor, rowsPerTile: 3}) + await setEditorHeightInLines(component, 5) + + global.debugContent = true + return + + expect(element.querySelectorAll('.line').length).toBe(3) + expect(component.getScrollHeight()).toBe( + editor.getScreenLineCount() * component.getLineHeight() + + item1.offsetHeight + item2.offsetHeight + item3.offsetHeight + + item4.offsetHeight + item5.offsetHeight + item6.offsetHeight + ) + expect(tileNodeForScreenRow(0).offsetHeight).toBe( + 3 * component.getLineHeight() + item1.offsetHeight + item2.offsetHeight + ) + expect(item1.previousSibling).toBeNull() + expect(item1.nextSibling).toBe(lineNodeForScreenRow(component, 0)) + expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 1)) + expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 0)) + expect(element.contains(item3)).toBe(false) + expect(element.contains(item4)).toBe(false) + expect(element.contains(item5)).toBe(false) + expect(element.contains(item6)).toBe(false) + }) + + function createBlockDecorationAtScreenRow(editor, screenRow, {height, margin, position}) { + const marker = editor.markScreenPosition([screenRow, 0], {invalidate: 'never'}) + const item = document.createElement('div') + item.style.height = height + 'px' + if (margin != null) item.style.margin = margin + 'px' + item.style.width = 30 + 'px' + const decoration = editor.decorateMarker(marker, {type: 'block', item, position}) + return {item, decoration} + } + }) + describe('mouse input', () => { describe('on the lines', () => { it('positions the cursor on single-click', async () => { @@ -1831,7 +1877,7 @@ describe('TextEditorComponent', () => { }) }) -function buildComponent (params = {}) { +function buildEditor (params = {}) { const text = params.text != null ? params.text : SAMPLE_TEXT const buffer = new TextBuffer({text}) const editorParams = {buffer} @@ -1839,7 +1885,11 @@ function buildComponent (params = {}) { for (const paramName of ['mini', 'autoHeight', 'autoWidth', 'lineNumberGutterVisible', 'placeholderText']) { if (params[paramName] != null) editorParams[paramName] = params[paramName] } - const editor = new TextEditor(editorParams) + return new TextEditor(editorParams) +} + +function buildComponent (params = {}) { + const editor = params.editor || buildEditor(params) const component = new TextEditorComponent({ model: editor, rowsPerTile: params.rowsPerTile, diff --git a/src/text-editor-component.js b/src/text-editor-component.js index a49e7fd30e4..daec209819e 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2,6 +2,7 @@ const etch = require('etch') const {CompositeDisposable} = require('event-kit') const {Point, Range} = require('text-buffer') const ResizeDetector = require('element-resize-detector') +const LineTopIndex = require('line-top-index') const TextEditor = require('./text-editor') const {isPairedCharacter} = require('./text-utils') const $ = etch.dom @@ -19,6 +20,15 @@ const MOUSE_DRAG_AUTOSCROLL_MARGIN = 40 const MOUSE_WHEEL_SCROLL_SENSITIVITY = 0.8 const CURSOR_BLINK_RESUME_DELAY = 300 const CURSOR_BLINK_PERIOD = 800 +const BLOCK_DECORATION_MEASUREMENT_AREA_VNODE = $.div({ + ref: 'blockDecorationMeasurementArea', + key: 'blockDecorationMeasurementArea', + style: { + contain: 'strict', + position: 'absolute', + visibility: 'hidden' + } +}) function scaleMouseDragAutoscrollDelta (delta) { return Math.pow(delta / 3, 3) / 280 @@ -57,6 +67,7 @@ class TextEditorComponent { this.didScrollDummyScrollbar = this.didScrollDummyScrollbar.bind(this) this.didMouseDownOnContent = this.didMouseDownOnContent.bind(this) this.disposables = new CompositeDisposable() + this.lineTopIndex = new LineTopIndex() this.updateScheduled = false this.measurements = null this.visible = false @@ -149,6 +160,8 @@ class TextEditorComponent { return } + this.measureBlockDecorations() + this.measuredContent = false this.updateSyncBeforeMeasuringContent() if (useScheduler === true) { @@ -167,6 +180,31 @@ class TextEditorComponent { } } + measureBlockDecorations () { + const {blockDecorationMeasurementArea} = this.refs + + blockDecorationMeasurementArea.appendChild(document.createElement('div')) + this.blockDecorationsToMeasure.forEach((decoration) => { + const {item} = decoration.getProperties() + blockDecorationMeasurementArea.appendChild(TextEditor.viewForItem(item)) + blockDecorationMeasurementArea.appendChild(document.createElement('div')) + }) + + this.blockDecorationsToMeasure.forEach((decoration) => { + const {item, position} = decoration.getProperties() + const decorationElement = TextEditor.viewForItem(item) + const {previousSibling, nextSibling} = decorationElement + const height = nextSibling.offsetTop - previousSibling.offsetTop + const row = decoration.getMarker().getHeadScreenPosition().row + this.lineTopIndex.insertBlock(decoration.id, row, height, position === 'after') + }) + + while (blockDecorationMeasurementArea.firstChild) { + blockDecorationMeasurementArea.firstChild.remove() + } + this.blockDecorationsToMeasure.clear() + } + updateSyncBeforeMeasuringContent () { this.horizontalPositionsToMeasure.clear() if (this.pendingAutoscroll) this.autoscrollVertically() @@ -230,9 +268,13 @@ class TextEditorComponent { if (this.measurements) { if (model.getAutoHeight()) { style.height = this.getContentHeight() + 'px' + } else { + style.height = this.element.style.height } if (model.getAutoWidth()) { style.width = this.getGutterContainerWidth() + this.getContentWidth() + 'px' + } else { + style.width = this.element.style.width } } @@ -393,15 +435,19 @@ class TextEditorComponent { children = [ this.renderCursorsAndInput(), this.renderLineTiles(), + BLOCK_DECORATION_MEASUREMENT_AREA_VNODE, this.renderPlaceholderText() ] } else { - children = $.div({ref: 'characterMeasurementLine', className: 'line'}, - $.span({ref: 'normalWidthCharacterSpan'}, NORMAL_WIDTH_CHARACTER), - $.span({ref: 'doubleWidthCharacterSpan'}, DOUBLE_WIDTH_CHARACTER), - $.span({ref: 'halfWidthCharacterSpan'}, HALF_WIDTH_CHARACTER), - $.span({ref: 'koreanCharacterSpan'}, KOREAN_CHARACTER) - ) + children = [ + BLOCK_DECORATION_MEASUREMENT_AREA_VNODE, + $.div({ref: 'characterMeasurementLine', className: 'line'}, + $.span({ref: 'normalWidthCharacterSpan'}, NORMAL_WIDTH_CHARACTER), + $.span({ref: 'doubleWidthCharacterSpan'}, DOUBLE_WIDTH_CHARACTER), + $.span({ref: 'halfWidthCharacterSpan'}, HALF_WIDTH_CHARACTER), + $.span({ref: 'koreanCharacterSpan'}, KOREAN_CHARACTER) + ) + ] } return $.div( @@ -1569,6 +1615,7 @@ class TextEditorComponent { this.measurements.halfWidthCharacterWidth, this.measurements.koreanCharacterWidth ) + this.lineTopIndex.setDefaultLineHeight(this.measurements.lineHeight) } measureGutterDimensions () { @@ -1792,6 +1839,7 @@ class TextEditorComponent { this.disposables.add(model.onDidRemoveGutter(scheduleUpdate)) this.disposables.add(model.selectionsMarkerLayer.onDidUpdate(this.didUpdateSelections.bind(this))) this.disposables.add(model.onDidRequestAutoscroll(this.didRequestAutoscroll.bind(this))) + this.blockDecorationsToMeasure = new Set(model.getDecorations({type: 'block'})) } isVisible () { From 5a6935a01cbf80c45df99c8ed8768881e10d5c4a Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 31 Mar 2017 13:46:05 -0600 Subject: [PATCH 190/403] Use `LineTopIndex` to convert from/to rows to/from pixel positions --- spec/text-editor-component-spec.js | 8 ++-- src/text-editor-component.js | 63 +++++++++++++++--------------- 2 files changed, 35 insertions(+), 36 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 2a820247901..5db1f75f413 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -294,7 +294,7 @@ describe('TextEditorComponent', () => { await setScrollLeft(component, 40) expect(component.getRenderedStartRow()).toBe(4) - expect(component.getRenderedEndRow()).toBe(12) + expect(component.getRenderedEndRow()).toBe(10) // When out of view, the hidden input is positioned at 0, 0 expect(editor.getCursorScreenPosition()).toEqual([0, 0]) @@ -480,7 +480,7 @@ describe('TextEditorComponent', () => { describe('autoscroll', () => { it('automatically scrolls vertically when the requested range is within the vertical scroll margin of the top or bottom', async () => { const {component, editor} = buildComponent({height: 120}) - expect(component.getLastVisibleRow()).toBe(8) + expect(component.getLastVisibleRow()).toBe(7) editor.scrollToScreenRange([[4, 0], [6, 0]]) await component.getNextUpdatePromise() @@ -503,7 +503,7 @@ describe('TextEditorComponent', () => { const {component, element, editor} = buildComponent({autoHeight: false}) element.style.height = 5.5 * component.measurements.lineHeight + 'px' await component.getNextUpdatePromise() - expect(component.getLastVisibleRow()).toBe(6) + expect(component.getLastVisibleRow()).toBe(5) const scrollMarginInLines = 2 editor.scrollToScreenPosition([6, 0]) @@ -525,7 +525,7 @@ describe('TextEditorComponent', () => { it('autoscrolls the given range to the center of the screen if the `center` option is true', async () => { const {component, editor} = buildComponent({height: 50}) - expect(component.getLastVisibleRow()).toBe(3) + expect(component.getLastVisibleRow()).toBe(2) editor.scrollToScreenRange([[4, 0], [6, 0]], {center: true}) await component.getNextUpdatePromise() diff --git a/src/text-editor-component.js b/src/text-editor-component.js index daec209819e..ff013034edb 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -468,7 +468,6 @@ class TextEditorComponent { const startRow = this.getRenderedStartRow() const endRow = this.getRenderedEndRow() const rowsPerTile = this.getRowsPerTile() - const tileHeight = this.getLineHeight() * rowsPerTile const tileWidth = this.getScrollWidth() const displayLayer = this.props.model.displayLayer @@ -476,6 +475,7 @@ class TextEditorComponent { for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow = tileStartRow + rowsPerTile) { const tileEndRow = Math.min(endRow, tileStartRow + rowsPerTile) + const tileHeight = this.pixelPositionBeforeBlocksForRow(tileEndRow) - this.pixelPositionBeforeBlocksForRow(tileStartRow) const tileIndex = this.tileIndexForTileStartRow(tileStartRow) const highlightDecorations = this.decorationsToRender.highlights.get(tileStartRow) @@ -485,7 +485,7 @@ class TextEditorComponent { measuredContent: this.measuredContent, height: tileHeight, width: tileWidth, - top: this.topPixelPositionForRow(tileStartRow), + top: this.pixelPositionBeforeBlocksForRow(tileStartRow), lineHeight: this.getLineHeight(), renderedStartRow: startRow, tileStartRow, tileEndRow, @@ -928,8 +928,8 @@ class TextEditorComponent { decorations = [] this.decorationsToRender.customGutter.set(decoration.gutterName, decorations) } - const top = this.pixelTopForRow(screenRange.start.row) - const height = this.pixelTopForRow(screenRange.end.row + 1) - top + const top = this.pixelPositionAfterBlocksForRow(screenRange.start.row) + const height = this.pixelPositionBeforeBlocksForRow(screenRange.end.row + 1) - top decorations.push({ className: decoration.class, @@ -950,9 +950,9 @@ class TextEditorComponent { for (let i = 0, length = highlights.length; i < length; i++) { const highlight = highlights[i] const {start, end} = highlight.screenRange - highlight.startPixelTop = this.pixelTopForRow(start.row) + highlight.startPixelTop = this.pixelPositionAfterBlocksForRow(start.row) highlight.startPixelLeft = this.pixelLeftForRowAndColumn(start.row, start.column) - highlight.endPixelTop = this.pixelTopForRow(end.row + 1) + highlight.endPixelTop = this.pixelPositionBeforeBlocksForRow(end.row + 1) highlight.endPixelLeft = this.pixelLeftForRowAndColumn(end.row, end.column) } this.decorationsToRender.highlights.set(tileRow, highlights) @@ -967,7 +967,7 @@ class TextEditorComponent { const cursor = this.decorationsToMeasure.cursors[i] const {row, column} = cursor.screenPosition - const pixelTop = this.pixelTopForRow(row) + const pixelTop = this.pixelPositionAfterBlocksForRow(row) const pixelLeft = this.pixelLeftForRowAndColumn(row, column) const pixelRight = (cursor.columnWidth === 0) ? pixelLeft @@ -991,7 +991,7 @@ class TextEditorComponent { const decoration = this.decorationsToRender.overlays[i] const {element, screenPosition, avoidOverflow} = decoration const {row, column} = screenPosition - let wrapperTop = contentClientRect.top + this.pixelTopForRow(row) + this.getLineHeight() + let wrapperTop = contentClientRect.top + this.pixelPositionAfterBlocksForRow(row) + this.getLineHeight() let wrapperLeft = contentClientRect.left + this.pixelLeftForRowAndColumn(row, column) if (avoidOverflow !== false) { @@ -1507,8 +1507,8 @@ class TextEditorComponent { autoscrollVertically () { const {screenRange, options} = this.pendingAutoscroll - const screenRangeTop = this.pixelTopForRow(screenRange.start.row) - const screenRangeBottom = this.pixelTopForRow(screenRange.end.row) + this.getLineHeight() + const screenRangeTop = this.pixelPositionAfterBlocksForRow(screenRange.start.row) + const screenRangeBottom = this.pixelPositionAfterBlocksForRow(screenRange.end.row) + this.getLineHeight() const verticalScrollMargin = this.getVerticalAutoscrollMargin() this.requestHorizontalMeasurement(screenRange.start.row, screenRange.start.column) @@ -1747,8 +1747,16 @@ class TextEditorComponent { } } - pixelTopForRow (row) { - return row * this.getLineHeight() + rowForPixelPosition (pixelPosition) { + return Math.max(0, this.lineTopIndex.rowForPixelPosition(pixelPosition)) + } + + pixelPositionBeforeBlocksForRow (row) { + return this.lineTopIndex.pixelPositionBeforeBlocksForRow(row) + } + + pixelPositionAfterBlocksForRow (row) { + return this.lineTopIndex.pixelPositionAfterBlocksForRow(row) } pixelLeftForRowAndColumn (row, column) { @@ -1761,7 +1769,7 @@ class TextEditorComponent { const {model} = this.props const row = Math.min( - Math.max(0, Math.floor(top / this.measurements.lineHeight)), + this.rowForPixelPosition(top), model.getApproximateScreenLineCount() - 1 ) @@ -1890,10 +1898,6 @@ class TextEditorComponent { } } - getScrollContainerHeightInLines () { - return Math.ceil(this.getScrollContainerHeight() / this.getLineHeight()) - } - getScrollContainerClientWidth () { if (this.isVerticalScrollbarVisible()) { return this.getScrollContainerWidth() - this.getVerticalScrollbarWidth() @@ -1957,7 +1961,7 @@ class TextEditorComponent { } getContentHeight () { - return this.props.model.getApproximateScreenLineCount() * this.getLineHeight() + return this.pixelPositionAfterBlocksForRow(this.props.model.getApproximateScreenLineCount()) } getContentWidth () { @@ -2016,13 +2020,13 @@ class TextEditorComponent { } getFirstVisibleRow () { - return Math.floor(this.getScrollTop() / this.getLineHeight()) + return this.rowForPixelPosition(this.getScrollTop()) } getLastVisibleRow () { return Math.min( this.props.model.getApproximateScreenLineCount() - 1, - this.getFirstVisibleRow() + this.getScrollContainerHeightInLines() + this.rowForPixelPosition(this.getScrollBottom()) ) } @@ -2030,7 +2034,6 @@ class TextEditorComponent { return Math.floor((this.getLastVisibleRow() - this.getFirstVisibleRow()) / this.getRowsPerTile()) + 2 } - getScrollTop () { this.scrollTop = Math.min(this.getMaxScrollTop(), this.scrollTop) return this.scrollTop @@ -2094,10 +2097,6 @@ class TextEditorComponent { this.props.model.displayLayer.populateSpatialIndexIfNeeded(Infinity, endRow) } - topPixelPositionForRow (row) { - return row * this.getLineHeight() - } - getNextUpdatePromise () { if (!this.nextUpdatePromise) { this.nextUpdatePromise = new Promise((resolve) => { @@ -2241,8 +2240,6 @@ class LineNumberGutterComponent { if (numbers) { const renderedTileCount = parentComponent.getRenderedTileCount() children = new Array(renderedTileCount) - const tileHeight = rowsPerTile * lineHeight + 'px' - const tileWidth = width + 'px' let softWrapCount = 0 for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow = tileStartRow + rowsPerTile) { @@ -2270,7 +2267,9 @@ class LineNumberGutterComponent { } const tileIndex = parentComponent.tileIndexForTileStartRow(tileStartRow) - const top = tileStartRow * lineHeight + const tileTop = parentComponent.pixelPositionBeforeBlocksForRow(tileStartRow) + const tileBottom = parentComponent.pixelPositionBeforeBlocksForRow(tileEndRow) + const tileHeight = tileBottom - tileTop children[tileIndex] = $.div({ key: tileIndex, @@ -2279,10 +2278,10 @@ class LineNumberGutterComponent { overflow: 'hidden', position: 'absolute', top: 0, - height: tileHeight, - width: tileWidth, + height: tileHeight + 'px', + width: width + 'px', willChange: 'transform', - transform: `translateY(${top}px)`, + transform: `translateY(${tileTop}px)`, backgroundColor: 'inherit' } }, ...tileChildren) @@ -2491,7 +2490,7 @@ class LinesTileComponent { for (let row = tileStartRow; row < tileEndRow; row++) { const screenLine = screenLines[row - renderedStartRow] if (!screenLine) { - children.length = i + children.length = row break } children[row - tileStartRow] = $(LineComponent, { From f7632a90955e42c89de69a9847858c26b66ebf75 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 5 Apr 2017 14:47:29 +0200 Subject: [PATCH 191/403] Add fast path when no block decorations need to be measured --- src/text-editor-component.js | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index ff013034edb..d0d6cd36764 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -181,28 +181,30 @@ class TextEditorComponent { } measureBlockDecorations () { - const {blockDecorationMeasurementArea} = this.refs + if (this.blockDecorationsToMeasure.size > 0) { + const {blockDecorationMeasurementArea} = this.refs - blockDecorationMeasurementArea.appendChild(document.createElement('div')) - this.blockDecorationsToMeasure.forEach((decoration) => { - const {item} = decoration.getProperties() - blockDecorationMeasurementArea.appendChild(TextEditor.viewForItem(item)) blockDecorationMeasurementArea.appendChild(document.createElement('div')) - }) + this.blockDecorationsToMeasure.forEach((decoration) => { + const {item} = decoration.getProperties() + blockDecorationMeasurementArea.appendChild(TextEditor.viewForItem(item)) + blockDecorationMeasurementArea.appendChild(document.createElement('div')) + }) - this.blockDecorationsToMeasure.forEach((decoration) => { - const {item, position} = decoration.getProperties() - const decorationElement = TextEditor.viewForItem(item) - const {previousSibling, nextSibling} = decorationElement - const height = nextSibling.offsetTop - previousSibling.offsetTop - const row = decoration.getMarker().getHeadScreenPosition().row - this.lineTopIndex.insertBlock(decoration.id, row, height, position === 'after') - }) + this.blockDecorationsToMeasure.forEach((decoration) => { + const {item, position} = decoration.getProperties() + const decorationElement = TextEditor.viewForItem(item) + const {previousSibling, nextSibling} = decorationElement + const height = nextSibling.offsetTop - previousSibling.offsetTop + const row = decoration.getMarker().getHeadScreenPosition().row + this.lineTopIndex.insertBlock(decoration.id, row, height, position === 'after') + }) - while (blockDecorationMeasurementArea.firstChild) { - blockDecorationMeasurementArea.firstChild.remove() + while (blockDecorationMeasurementArea.firstChild) { + blockDecorationMeasurementArea.firstChild.remove() + } + this.blockDecorationsToMeasure.clear() } - this.blockDecorationsToMeasure.clear() } updateSyncBeforeMeasuringContent () { From 7a0a41a7dffea0ee2ffdb87515e9177d2105ad53 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 5 Apr 2017 15:14:38 +0200 Subject: [PATCH 192/403] Use `Array.push` instead of `array[i] =` when adding line components Albeit (potentially) slower, this will allow to add a dynamic number of block decoration nodes before and after a given line. --- src/text-editor-component.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index d0d6cd36764..6381123add0 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2488,21 +2488,21 @@ class LinesTileComponent { } = this.props if (!measuredContent || !this.linesVnode) { - const children = new Array(tileEndRow - tileStartRow) + const children = [] for (let row = tileStartRow; row < tileEndRow; row++) { const screenLine = screenLines[row - renderedStartRow] if (!screenLine) { - children.length = row break } - children[row - tileStartRow] = $(LineComponent, { + + children.push($(LineComponent, { key: screenLine.id, screenLine, lineDecoration: lineDecorations[row - renderedStartRow], displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId - }) + })) } this.linesVnode = $.div({ From e28928320518d8db7f5d96516b0449618e4b7fb9 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 5 Apr 2017 16:21:30 +0200 Subject: [PATCH 193/403] Render block decorations between lines --- spec/text-editor-component-spec.js | 50 +++++++++++++++++++------ src/text-editor-component.js | 59 ++++++++++++++++++++++++++++-- 2 files changed, 94 insertions(+), 15 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 5db1f75f413..44789a6d6af 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1158,7 +1158,7 @@ describe('TextEditorComponent', () => { describe('block decorations', () => { ffit('renders visible and yet-to-be-measured block decorations, inserting them between the appropriate lines and refreshing them as needed', async () => { - const editor = buildEditor() + const editor = buildEditor({autoHeight: false}) const {item: item1, decoration: decoration1} = createBlockDecorationAtScreenRow(editor, 0, {height: 80, position: 'before'}) const {item: item2, decoration: decoration2} = createBlockDecorationAtScreenRow(editor, 2, {height: 40, margin: 12, position: 'before'}) const {item: item3, decoration: decoration3} = createBlockDecorationAtScreenRow(editor, 4, {height: 100, position: 'before'}) @@ -1167,25 +1167,26 @@ describe('TextEditorComponent', () => { const {item: item6, decoration: decoration6} = createBlockDecorationAtScreenRow(editor, 12, {height: 22, position: 'after'}) const {component, element} = buildComponent({editor, rowsPerTile: 3}) - await setEditorHeightInLines(component, 5) + await setEditorHeightInLines(component, 10) - global.debugContent = true - return - - expect(element.querySelectorAll('.line').length).toBe(3) expect(component.getScrollHeight()).toBe( editor.getScreenLineCount() * component.getLineHeight() + - item1.offsetHeight + item2.offsetHeight + item3.offsetHeight + - item4.offsetHeight + item5.offsetHeight + item6.offsetHeight + getElementHeight(item1) + getElementHeight(item2) + getElementHeight(item3) + + getElementHeight(item4) + getElementHeight(item5) + getElementHeight(item6) + ) + expect(tileNodeForScreenRow(component, 0).offsetHeight).toBe( + 3 * component.getLineHeight() + getElementHeight(item1) + getElementHeight(item2) ) - expect(tileNodeForScreenRow(0).offsetHeight).toBe( - 3 * component.getLineHeight() + item1.offsetHeight + item2.offsetHeight + expect(tileNodeForScreenRow(component, 3).offsetHeight).toBe( + 3 * component.getLineHeight() + getElementHeight(item3) ) + expect(element.querySelectorAll('.line').length).toBe(6) expect(item1.previousSibling).toBeNull() expect(item1.nextSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 1)) - expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 0)) - expect(element.contains(item3)).toBe(false) + expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 2)) + expect(item3.previousSibling).toBe(lineNodeForScreenRow(component, 3)) + expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 4)) expect(element.contains(item4)).toBe(false) expect(element.contains(item5)).toBe(false) expect(element.contains(item6)).toBe(false) @@ -1962,6 +1963,10 @@ function lineNumberNodeForScreenRow (component, row) { return gutterElement.children[tileIndex + 1].children[row - tileStartRow] } +function tileNodeForScreenRow (component, row) { + return lineNodeForScreenRow(component, row).parentElement +} + function lineNodeForScreenRow (component, row) { const renderedScreenLine = component.renderedScreenLineForRow(row) return component.lineNodesByScreenLineId.get(renderedScreenLine.id) @@ -1999,3 +2004,24 @@ function assertDocumentFocused () { throw new Error('The document needs to be focused to run this test') } } + +function getElementHeight (element) { + const topRuler = document.createElement('div') + const bottomRuler = document.createElement('div') + let height + if (document.body.contains(element)) { + element.parentElement.insertBefore(topRuler, element) + element.parentElement.insertBefore(bottomRuler, element.nextSibling) + height = bottomRuler.offsetTop - topRuler.offsetTop + } else { + jasmine.attachToDOM(topRuler) + jasmine.attachToDOM(element) + jasmine.attachToDOM(bottomRuler) + height = bottomRuler.offsetTop - topRuler.offsetTop + element.remove() + } + + topRuler.remove() + bottomRuler.remove() + return height +} diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 6381123add0..ae640a75fb5 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -104,7 +104,8 @@ class TextEditorComponent { highlights: new Map(), cursors: [], overlays: [], - customGutter: new Map() + customGutter: new Map(), + blocks: new Map() } this.decorationsToMeasure = { highlights: new Map(), @@ -493,6 +494,7 @@ class TextEditorComponent { tileStartRow, tileEndRow, screenLines: this.renderedScreenLines, lineDecorations: this.decorationsToRender.lines, + blockDecorations: this.decorationsToRender.blocks, highlightDecorations, displayLayer, lineNodesByScreenLineId, @@ -781,10 +783,10 @@ class TextEditorComponent { this.decorationsToRender.lines = [] this.decorationsToRender.overlays.length = 0 this.decorationsToRender.customGutter.clear() + this.decorationsToRender.blocks.clear() this.decorationsToMeasure.highlights.clear() this.decorationsToMeasure.cursors.length = 0 - const decorationsByMarker = this.props.model.decorationManager.decorationPropertiesByMarkerForScreenRowRange( this.getRenderedStartRow(), @@ -824,6 +826,9 @@ class TextEditorComponent { case 'gutter': this.addCustomGutterDecorationToRender(decoration, screenRange) break + case 'block': + this.addBlockDecorationToRender(decoration, screenRange, reversed) + break } } } @@ -940,6 +945,16 @@ class TextEditorComponent { }) } + addBlockDecorationToRender (decoration, screenRange, reversed) { + const screenPosition = reversed ? screenRange.start : screenRange.end + let rowDecorations = this.decorationsToRender.blocks.get(screenPosition.row) + if (rowDecorations == null) { + rowDecorations = [] + this.decorationsToRender.blocks.set(screenPosition.row, rowDecorations) + } + rowDecorations.push(decoration) + } + updateAbsolutePositionedDecorations () { this.updateHighlightsToRender() this.updateCursorsToRender() @@ -2483,7 +2498,7 @@ class LinesTileComponent { const { measuredContent, height, width, top, renderedStartRow, tileStartRow, tileEndRow, - screenLines, lineDecorations, displayLayer, + screenLines, lineDecorations, blockDecorations, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId, } = this.props @@ -2495,6 +2510,19 @@ class LinesTileComponent { break } + const rowBlockDecorations = blockDecorations.get(row) + + if (rowBlockDecorations) { + for (let i = 0; i < rowBlockDecorations.length; i++) { + const blockDecoration = rowBlockDecorations[i] + if (blockDecoration.position == null || blockDecoration.position === 'before') { + children.push($(ElementComponent, { + element: TextEditor.viewForItem(blockDecoration.item) + })) + } + } + } + children.push($(LineComponent, { key: screenLine.id, screenLine, @@ -2503,6 +2531,17 @@ class LinesTileComponent { lineNodesByScreenLineId, textNodesByScreenLineId })) + + if (rowBlockDecorations) { + for (let i = 0; i < rowBlockDecorations.length; i++) { + const blockDecoration = rowBlockDecorations[i] + if (blockDecoration.position === 'after') { + children.push($(ElementComponent, { + element: TextEditor.viewForItem(blockDecoration.item) + })) + } + } + } } this.linesVnode = $.div({ @@ -2764,6 +2803,20 @@ class ComponentWrapper { } } +class ElementComponent { + constructor ({element}) { + this.element = element + } + + destroy () { + this.element.remove() + } + + update ({element}) { + this.element = element + } +} + const classNamesByScopeName = new Map() function classNameForScopeName (scopeName) { let classString = classNamesByScopeName.get(scopeName) From 015f196f2f607cc83fa3eb77c300defee9bbb140 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 5 Apr 2017 16:38:02 +0200 Subject: [PATCH 194/403] Test scrolling down with block decorations --- spec/text-editor-component-spec.js | 42 +++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 44789a6d6af..b48d1f6124d 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1159,16 +1159,18 @@ describe('TextEditorComponent', () => { describe('block decorations', () => { ffit('renders visible and yet-to-be-measured block decorations, inserting them between the appropriate lines and refreshing them as needed', async () => { const editor = buildEditor({autoHeight: false}) - const {item: item1, decoration: decoration1} = createBlockDecorationAtScreenRow(editor, 0, {height: 80, position: 'before'}) - const {item: item2, decoration: decoration2} = createBlockDecorationAtScreenRow(editor, 2, {height: 40, margin: 12, position: 'before'}) - const {item: item3, decoration: decoration3} = createBlockDecorationAtScreenRow(editor, 4, {height: 100, position: 'before'}) - const {item: item4, decoration: decoration4} = createBlockDecorationAtScreenRow(editor, 7, {height: 120, position: 'before'}) - const {item: item5, decoration: decoration5} = createBlockDecorationAtScreenRow(editor, 7, {height: 42, position: 'after'}) - const {item: item6, decoration: decoration6} = createBlockDecorationAtScreenRow(editor, 12, {height: 22, position: 'after'}) + const {item: item1, decoration: decoration1} = createBlockDecorationAtScreenRow(editor, 0, {height: 11, position: 'before'}) + const {item: item2, decoration: decoration2} = createBlockDecorationAtScreenRow(editor, 2, {height: 22, margin: 10, position: 'before'}) + const {item: item3, decoration: decoration3} = createBlockDecorationAtScreenRow(editor, 4, {height: 33, position: 'before'}) + const {item: item4, decoration: decoration4} = createBlockDecorationAtScreenRow(editor, 7, {height: 44, position: 'before'}) + const {item: item5, decoration: decoration5} = createBlockDecorationAtScreenRow(editor, 7, {height: 55, position: 'after'}) + const {item: item6, decoration: decoration6} = createBlockDecorationAtScreenRow(editor, 12, {height: 66, position: 'after'}) const {component, element} = buildComponent({editor, rowsPerTile: 3}) - await setEditorHeightInLines(component, 10) + await setEditorHeightInLines(component, 4) + expect(component.getRenderedStartRow()).toBe(0) + expect(component.getRenderedEndRow()).toBe(6) expect(component.getScrollHeight()).toBe( editor.getScreenLineCount() * component.getLineHeight() + getElementHeight(item1) + getElementHeight(item2) + getElementHeight(item3) + @@ -1190,6 +1192,32 @@ describe('TextEditorComponent', () => { expect(element.contains(item4)).toBe(false) expect(element.contains(item5)).toBe(false) expect(element.contains(item6)).toBe(false) + + // scroll past the first tile + await setScrollTop(component, 3 * component.getLineHeight() + getElementHeight(item1) + getElementHeight(item2)) + expect(component.getRenderedStartRow()).toBe(3) + expect(component.getRenderedEndRow()).toBe(9) + expect(component.getScrollHeight()).toBe( + editor.getScreenLineCount() * component.getLineHeight() + + getElementHeight(item1) + getElementHeight(item2) + getElementHeight(item3) + + getElementHeight(item4) + getElementHeight(item5) + getElementHeight(item6) + ) + expect(tileNodeForScreenRow(component, 3).offsetHeight).toBe( + 3 * component.getLineHeight() + getElementHeight(item3) + ) + expect(tileNodeForScreenRow(component, 6).offsetHeight).toBe( + 3 * component.getLineHeight() + getElementHeight(item4) + getElementHeight(item5) + ) + expect(element.querySelectorAll('.line').length).toBe(6) + expect(element.contains(item1)).toBe(false) + expect(element.contains(item2)).toBe(false) + expect(item3.previousSibling).toBe(lineNodeForScreenRow(component, 3)) + expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 4)) + expect(item4.previousSibling).toBe(lineNodeForScreenRow(component, 6)) + expect(item4.nextSibling).toBe(lineNodeForScreenRow(component, 7)) + expect(item5.previousSibling).toBe(lineNodeForScreenRow(component, 7)) + expect(item5.nextSibling).toBe(lineNodeForScreenRow(component, 8)) + expect(element.contains(item6)).toBe(false) }) function createBlockDecorationAtScreenRow(editor, screenRow, {height, margin, position}) { From 4cd9a36594a8c4844c7799cc0e055b1976dabf28 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 5 Apr 2017 19:32:53 +0200 Subject: [PATCH 195/403] Handle inserting and updating block decorations --- spec/text-editor-component-spec.js | 64 ++++++++++++++++++++++++++++-- src/decoration-manager.js | 5 ++- src/text-editor-component.js | 45 ++++++++++++++++++++- 3 files changed, 108 insertions(+), 6 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index b48d1f6124d..bc826995e4d 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1161,13 +1161,15 @@ describe('TextEditorComponent', () => { const editor = buildEditor({autoHeight: false}) const {item: item1, decoration: decoration1} = createBlockDecorationAtScreenRow(editor, 0, {height: 11, position: 'before'}) const {item: item2, decoration: decoration2} = createBlockDecorationAtScreenRow(editor, 2, {height: 22, margin: 10, position: 'before'}) + + const {component, element} = buildComponent({editor, rowsPerTile: 3}) + await setEditorHeightInLines(component, 4) + const {item: item3, decoration: decoration3} = createBlockDecorationAtScreenRow(editor, 4, {height: 33, position: 'before'}) const {item: item4, decoration: decoration4} = createBlockDecorationAtScreenRow(editor, 7, {height: 44, position: 'before'}) const {item: item5, decoration: decoration5} = createBlockDecorationAtScreenRow(editor, 7, {height: 55, position: 'after'}) const {item: item6, decoration: decoration6} = createBlockDecorationAtScreenRow(editor, 12, {height: 66, position: 'after'}) - - const {component, element} = buildComponent({editor, rowsPerTile: 3}) - await setEditorHeightInLines(component, 4) + await component.getNextUpdatePromise() expect(component.getRenderedStartRow()).toBe(0) expect(component.getRenderedEndRow()).toBe(6) @@ -1218,6 +1220,62 @@ describe('TextEditorComponent', () => { expect(item5.previousSibling).toBe(lineNodeForScreenRow(component, 7)) expect(item5.nextSibling).toBe(lineNodeForScreenRow(component, 8)) expect(element.contains(item6)).toBe(false) + + // destroy decoration1 + await setScrollTop(component, 0) + decoration1.destroy() + await component.getNextUpdatePromise() + + expect(component.getRenderedStartRow()).toBe(0) + expect(component.getRenderedEndRow()).toBe(6) + expect(component.getScrollHeight()).toBe( + editor.getScreenLineCount() * component.getLineHeight() + + getElementHeight(item2) + getElementHeight(item3) + + getElementHeight(item4) + getElementHeight(item5) + getElementHeight(item6) + ) + expect(tileNodeForScreenRow(component, 0).offsetHeight).toBe( + 3 * component.getLineHeight() + getElementHeight(item2) + ) + expect(tileNodeForScreenRow(component, 3).offsetHeight).toBe( + 3 * component.getLineHeight() + getElementHeight(item3) + ) + expect(element.querySelectorAll('.line').length).toBe(6) + expect(element.contains(item1)).toBe(false) + expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 1)) + expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 2)) + expect(item3.previousSibling).toBe(lineNodeForScreenRow(component, 3)) + expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 4)) + expect(element.contains(item4)).toBe(false) + expect(element.contains(item5)).toBe(false) + expect(element.contains(item6)).toBe(false) + + // move decoration2 and decoration3 + decoration2.getMarker().setHeadScreenPosition([1, 0]) + decoration3.getMarker().setHeadScreenPosition([3, 0]) + await component.getNextUpdatePromise() + + expect(component.getRenderedStartRow()).toBe(0) + expect(component.getRenderedEndRow()).toBe(6) + expect(component.getScrollHeight()).toBe( + editor.getScreenLineCount() * component.getLineHeight() + + getElementHeight(item2) + getElementHeight(item3) + + getElementHeight(item4) + getElementHeight(item5) + getElementHeight(item6) + ) + expect(tileNodeForScreenRow(component, 0).offsetHeight).toBe( + 3 * component.getLineHeight() + getElementHeight(item2) + ) + expect(tileNodeForScreenRow(component, 3).offsetHeight).toBe( + 3 * component.getLineHeight() + getElementHeight(item3) + ) + expect(element.querySelectorAll('.line').length).toBe(6) + expect(element.contains(item1)).toBe(false) + expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) + expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) + expect(item3.previousSibling).toBeNull() + expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 3)) + expect(element.contains(item4)).toBe(false) + expect(element.contains(item5)).toBe(false) + expect(element.contains(item6)).toBe(false) }) function createBlockDecorationAtScreenRow(editor, screenRow, {height, margin, position}) { diff --git a/src/decoration-manager.js b/src/decoration-manager.js index fc3692bce90..a37eafe7fca 100644 --- a/src/decoration-manager.js +++ b/src/decoration-manager.js @@ -17,7 +17,10 @@ class DecorationManager { } observeDecorations (callback) { - for (let decoration of this.getDecorations()) { callback(decoration) } + const decorations = this.getDecorations() + for (let i = 0; i < decorations.length; i++) { + callback(decorations[i]) + } return this.onDidAddDecoration(callback) } diff --git a/src/text-editor-component.js b/src/text-editor-component.js index ae640a75fb5..940069e5700 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -783,7 +783,7 @@ class TextEditorComponent { this.decorationsToRender.lines = [] this.decorationsToRender.overlays.length = 0 this.decorationsToRender.customGutter.clear() - this.decorationsToRender.blocks.clear() + this.decorationsToRender.blocks = new Map() this.decorationsToMeasure.highlights.clear() this.decorationsToMeasure.cursors.length = 0 @@ -1864,7 +1864,28 @@ class TextEditorComponent { this.disposables.add(model.onDidRemoveGutter(scheduleUpdate)) this.disposables.add(model.selectionsMarkerLayer.onDidUpdate(this.didUpdateSelections.bind(this))) this.disposables.add(model.onDidRequestAutoscroll(this.didRequestAutoscroll.bind(this))) - this.blockDecorationsToMeasure = new Set(model.getDecorations({type: 'block'})) + this.blockDecorationsToMeasure = new Set() + this.disposables.add(model.observeDecorations((decoration) => { + if (decoration.getProperties().type === 'block') this.observeBlockDecoration(decoration) + })) + } + + observeBlockDecoration (decoration) { + this.blockDecorationsToMeasure.add(decoration) + const marker = decoration.getMarker() + const didUpdateDisposable = marker.bufferMarker.onDidChange((e) => { + if (!e.textChanged) { + this.lineTopIndex.moveBlock(decoration.id, marker.getHeadScreenPosition().row) + this.scheduleUpdate() + } + }) + const didDestroyDisposable = decoration.onDidDestroy(() => { + this.blockDecorationsToMeasure.delete(decoration) + this.lineTopIndex.removeBlock(decoration.id) + didUpdateDisposable.dispose() + didDestroyDisposable.dispose() + this.scheduleUpdate() + }) } isVisible () { @@ -2582,6 +2603,26 @@ class LinesTileComponent { } } + if (oldProps.blockDecorations.size !== newProps.blockDecorations.size) return true + + let blockDecorationsChanged = false + + oldProps.blockDecorations.forEach((oldDecorations, row) => { + if (!blockDecorationsChanged) { + const newDecorations = newProps.blockDecorations.get(row) + blockDecorationsChanged = (newDecorations == null || !arraysEqual(oldDecorations, newDecorations)) + } + }) + if (blockDecorationsChanged) return true + + newProps.blockDecorations.forEach((newDecorations, row) => { + if (!blockDecorationsChanged) { + const oldDecorations = oldProps.blockDecorations.get(row) + blockDecorationsChanged = (oldDecorations == null) + } + }) + if (blockDecorationsChanged) return true + return false } } From 316df28bbd18f12a9e61743cdad77732e70a9e52 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 6 Apr 2017 12:13:38 +0200 Subject: [PATCH 196/403] Splice `LineTopIndex` when a textual change occurs --- package.json | 2 +- spec/text-editor-component-spec.js | 83 ++++++++++++++++++++++++++++-- src/text-editor-component.js | 56 +++++++++++++++----- 3 files changed, 124 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index ea3967d36ea..76cd869c072 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "jquery": "2.1.4", "key-path-helpers": "^0.4.0", "less-cache": "1.1.0", - "line-top-index": "0.2.0", + "line-top-index": "0.3.0", "marked": "^0.3.6", "minimatch": "^3.0.3", "mocha": "2.5.1", diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index bc826995e4d..0537b78f7a9 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1157,20 +1157,38 @@ describe('TextEditorComponent', () => { }) describe('block decorations', () => { - ffit('renders visible and yet-to-be-measured block decorations, inserting them between the appropriate lines and refreshing them as needed', async () => { + it('renders visible block decorations between the appropriate lines, refreshing and measuring them as needed', async () => { const editor = buildEditor({autoHeight: false}) const {item: item1, decoration: decoration1} = createBlockDecorationAtScreenRow(editor, 0, {height: 11, position: 'before'}) const {item: item2, decoration: decoration2} = createBlockDecorationAtScreenRow(editor, 2, {height: 22, margin: 10, position: 'before'}) + // render an editor that already contains some block decorations const {component, element} = buildComponent({editor, rowsPerTile: 3}) await setEditorHeightInLines(component, 4) + expect(component.getRenderedStartRow()).toBe(0) + expect(component.getRenderedEndRow()).toBe(6) + expect(component.getScrollHeight()).toBe( + editor.getScreenLineCount() * component.getLineHeight() + + getElementHeight(item1) + getElementHeight(item2) + ) + expect(tileNodeForScreenRow(component, 0).offsetHeight).toBe( + 3 * component.getLineHeight() + getElementHeight(item1) + getElementHeight(item2) + ) + expect(tileNodeForScreenRow(component, 3).offsetHeight).toBe( + 3 * component.getLineHeight() + ) + expect(element.querySelectorAll('.line').length).toBe(6) + expect(item1.previousSibling).toBeNull() + expect(item1.nextSibling).toBe(lineNodeForScreenRow(component, 0)) + expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 1)) + expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 2)) + // add block decorations const {item: item3, decoration: decoration3} = createBlockDecorationAtScreenRow(editor, 4, {height: 33, position: 'before'}) const {item: item4, decoration: decoration4} = createBlockDecorationAtScreenRow(editor, 7, {height: 44, position: 'before'}) const {item: item5, decoration: decoration5} = createBlockDecorationAtScreenRow(editor, 7, {height: 55, position: 'after'}) const {item: item6, decoration: decoration6} = createBlockDecorationAtScreenRow(editor, 12, {height: 66, position: 'after'}) await component.getNextUpdatePromise() - expect(component.getRenderedStartRow()).toBe(0) expect(component.getRenderedEndRow()).toBe(6) expect(component.getScrollHeight()).toBe( @@ -1225,7 +1243,6 @@ describe('TextEditorComponent', () => { await setScrollTop(component, 0) decoration1.destroy() await component.getNextUpdatePromise() - expect(component.getRenderedStartRow()).toBe(0) expect(component.getRenderedEndRow()).toBe(6) expect(component.getScrollHeight()).toBe( @@ -1253,7 +1270,59 @@ describe('TextEditorComponent', () => { decoration2.getMarker().setHeadScreenPosition([1, 0]) decoration3.getMarker().setHeadScreenPosition([3, 0]) await component.getNextUpdatePromise() + expect(component.getRenderedStartRow()).toBe(0) + expect(component.getRenderedEndRow()).toBe(6) + expect(component.getScrollHeight()).toBe( + editor.getScreenLineCount() * component.getLineHeight() + + getElementHeight(item2) + getElementHeight(item3) + + getElementHeight(item4) + getElementHeight(item5) + getElementHeight(item6) + ) + expect(tileNodeForScreenRow(component, 0).offsetHeight).toBe( + 3 * component.getLineHeight() + getElementHeight(item2) + ) + expect(tileNodeForScreenRow(component, 3).offsetHeight).toBe( + 3 * component.getLineHeight() + getElementHeight(item3) + ) + expect(element.querySelectorAll('.line').length).toBe(6) + expect(element.contains(item1)).toBe(false) + expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) + expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) + expect(item3.previousSibling).toBeNull() + expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 3)) + expect(element.contains(item4)).toBe(false) + expect(element.contains(item5)).toBe(false) + expect(element.contains(item6)).toBe(false) + + // change the text + editor.setCursorScreenPosition([0, 5]) + editor.insertNewline() + await component.getNextUpdatePromise() + expect(component.getRenderedStartRow()).toBe(0) + expect(component.getRenderedEndRow()).toBe(6) + expect(component.getScrollHeight()).toBe( + editor.getScreenLineCount() * component.getLineHeight() + + getElementHeight(item2) + getElementHeight(item3) + + getElementHeight(item4) + getElementHeight(item5) + getElementHeight(item6) + ) + expect(tileNodeForScreenRow(component, 0).offsetHeight).toBe( + 3 * component.getLineHeight() + getElementHeight(item2) + ) + expect(tileNodeForScreenRow(component, 3).offsetHeight).toBe( + 3 * component.getLineHeight() + getElementHeight(item3) + ) + expect(element.querySelectorAll('.line').length).toBe(6) + expect(element.contains(item1)).toBe(false) + expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 1)) + expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 2)) + expect(item3.previousSibling).toBe(lineNodeForScreenRow(component, 3)) + expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 4)) + expect(element.contains(item4)).toBe(false) + expect(element.contains(item5)).toBe(false) + expect(element.contains(item6)).toBe(false) + // undo the previous change + editor.undo() + await component.getNextUpdatePromise() expect(component.getRenderedStartRow()).toBe(0) expect(component.getRenderedEndRow()).toBe(6) expect(component.getScrollHeight()).toBe( @@ -1696,6 +1765,8 @@ describe('TextEditorComponent', () => { const {component, editor} = buildComponent() spyOn(component, 'handleMouseDragUntilMouseUp') editor.setSoftWrapped(true) + await component.getNextUpdatePromise() + await setEditorWidthInCharacters(component, 50) editor.foldBufferRange([[4, Infinity], [7, Infinity]]) await component.getNextUpdatePromise() @@ -1721,6 +1792,8 @@ describe('TextEditorComponent', () => { it('adds new selections when a line number is meta-clicked', async () => { const {component, editor} = buildComponent() editor.setSoftWrapped(true) + await component.getNextUpdatePromise() + await setEditorWidthInCharacters(component, 50) editor.foldBufferRange([[4, Infinity], [7, Infinity]]) await component.getNextUpdatePromise() @@ -1763,6 +1836,8 @@ describe('TextEditorComponent', () => { const {component, editor} = buildComponent() spyOn(component, 'handleMouseDragUntilMouseUp') editor.setSoftWrapped(true) + await component.getNextUpdatePromise() + await setEditorWidthInCharacters(component, 50) editor.foldBufferRange([[4, Infinity], [7, Infinity]]) await component.getNextUpdatePromise() @@ -1804,6 +1879,8 @@ describe('TextEditorComponent', () => { const {component, editor} = buildComponent() spyOn(component, 'handleMouseDragUntilMouseUp') editor.setSoftWrapped(true) + await component.getNextUpdatePromise() + await setEditorWidthInCharacters(component, 50) editor.foldBufferRange([[4, Infinity], [7, Infinity]]) await component.getNextUpdatePromise() diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 940069e5700..f88dcb7de49 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -193,12 +193,11 @@ class TextEditorComponent { }) this.blockDecorationsToMeasure.forEach((decoration) => { - const {item, position} = decoration.getProperties() + const {item} = decoration.getProperties() const decorationElement = TextEditor.viewForItem(item) const {previousSibling, nextSibling} = decorationElement const height = nextSibling.offsetTop - previousSibling.offsetTop - const row = decoration.getMarker().getHeadScreenPosition().row - this.lineTopIndex.insertBlock(decoration.id, row, height, position === 'after') + this.lineTopIndex.resizeBlock(decoration, height) }) while (blockDecorationMeasurementArea.firstChild) { @@ -1858,7 +1857,25 @@ class TextEditorComponent { const {model} = this.props model.component = this const scheduleUpdate = this.scheduleUpdate.bind(this) - this.disposables.add(model.displayLayer.onDidChangeSync(scheduleUpdate)) + this.disposables.add(model.displayLayer.onDidReset(() => { + this.spliceLineTopIndex(0, Infinity, Infinity) + this.scheduleUpdate() + })) + this.disposables.add(model.displayLayer.onDidChangeSync((changes) => { + for (let i = 0; i < changes.length; i++) { + const change = changes[i] + const startRow = change.start.row + const endRow = startRow + change.oldExtent.row + const rowDelta = change.newExtent.row - change.oldExtent.row + this.spliceLineTopIndex( + change.start.row, + change.oldExtent.row, + change.newExtent.row + ) + } + + this.scheduleUpdate() + })) this.disposables.add(model.onDidUpdateDecorations(scheduleUpdate)) this.disposables.add(model.onDidAddGutter(scheduleUpdate)) this.disposables.add(model.onDidRemoveGutter(scheduleUpdate)) @@ -1871,23 +1888,36 @@ class TextEditorComponent { } observeBlockDecoration (decoration) { - this.blockDecorationsToMeasure.add(decoration) const marker = decoration.getMarker() + const {item, position} = decoration.getProperties() + const row = marker.getHeadScreenPosition().row + this.lineTopIndex.insertBlock(decoration, row, 0, position === 'after') + + this.blockDecorationsToMeasure.add(decoration) + const didUpdateDisposable = marker.bufferMarker.onDidChange((e) => { if (!e.textChanged) { - this.lineTopIndex.moveBlock(decoration.id, marker.getHeadScreenPosition().row) + this.lineTopIndex.moveBlock(decoration, marker.getHeadScreenPosition().row) this.scheduleUpdate() } }) const didDestroyDisposable = decoration.onDidDestroy(() => { this.blockDecorationsToMeasure.delete(decoration) - this.lineTopIndex.removeBlock(decoration.id) + this.lineTopIndex.removeBlock(decoration) didUpdateDisposable.dispose() didDestroyDisposable.dispose() this.scheduleUpdate() }) } + spliceLineTopIndex (startRow, oldExtent, newExtent) { + const invalidatedBlockDecorations = this.lineTopIndex.splice(startRow, oldExtent, newExtent) + invalidatedBlockDecorations.forEach((decoration) => { + const newPosition = decoration.getMarker().getHeadScreenPosition() + this.lineTopIndex.moveBlock(decoration, newPosition.row) + }) + } + isVisible () { return this.element.offsetWidth > 0 || this.element.offsetHeight > 0 } @@ -2537,8 +2567,10 @@ class LinesTileComponent { for (let i = 0; i < rowBlockDecorations.length; i++) { const blockDecoration = rowBlockDecorations[i] if (blockDecoration.position == null || blockDecoration.position === 'before') { + const element = TextEditor.viewForItem(blockDecoration.item) children.push($(ElementComponent, { - element: TextEditor.viewForItem(blockDecoration.item) + key: element, + element })) } } @@ -2557,8 +2589,10 @@ class LinesTileComponent { for (let i = 0; i < rowBlockDecorations.length; i++) { const blockDecoration = rowBlockDecorations[i] if (blockDecoration.position === 'after') { + const element = TextEditor.viewForItem(blockDecoration.item) children.push($(ElementComponent, { - element: TextEditor.viewForItem(blockDecoration.item) + key: element, + element })) } } @@ -2849,10 +2883,6 @@ class ElementComponent { this.element = element } - destroy () { - this.element.remove() - } - update ({element}) { this.element = element } From 919c5a022bad5de37a7da0a99da860972fb97cf5 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 7 Apr 2017 12:11:40 +0200 Subject: [PATCH 197/403] Don't use etch for lines rendering --- src/text-editor-component.js | 208 ++++++++++++++++++++++++++--------- 1 file changed, 153 insertions(+), 55 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index f88dcb7de49..28562fb7f11 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2550,63 +2550,16 @@ class LinesTileComponent { measuredContent, height, width, top, renderedStartRow, tileStartRow, tileEndRow, screenLines, lineDecorations, blockDecorations, displayLayer, - lineNodesByScreenLineId, textNodesByScreenLineId, + lineNodesByScreenLineId, textNodesByScreenLineId } = this.props if (!measuredContent || !this.linesVnode) { - const children = [] - for (let row = tileStartRow; row < tileEndRow; row++) { - const screenLine = screenLines[row - renderedStartRow] - if (!screenLine) { - break - } - - const rowBlockDecorations = blockDecorations.get(row) - - if (rowBlockDecorations) { - for (let i = 0; i < rowBlockDecorations.length; i++) { - const blockDecoration = rowBlockDecorations[i] - if (blockDecoration.position == null || blockDecoration.position === 'before') { - const element = TextEditor.viewForItem(blockDecoration.item) - children.push($(ElementComponent, { - key: element, - element - })) - } - } - } - - children.push($(LineComponent, { - key: screenLine.id, - screenLine, - lineDecoration: lineDecorations[row - renderedStartRow], - displayLayer, - lineNodesByScreenLineId, - textNodesByScreenLineId - })) - - if (rowBlockDecorations) { - for (let i = 0; i < rowBlockDecorations.length; i++) { - const blockDecoration = rowBlockDecorations[i] - if (blockDecoration.position === 'after') { - const element = TextEditor.viewForItem(blockDecoration.item) - children.push($(ElementComponent, { - key: element, - element - })) - } - } - } - } - - this.linesVnode = $.div({ - style: { - position: 'absolute', - contain: 'strict', - height: height + 'px', - width: width + 'px' - } - }, children) + this.linesVnode = $(LinesComponent, { + height, width, + renderedStartRow, tileStartRow, tileEndRow, + screenLines, lineDecorations, blockDecorations, displayLayer, + lineNodesByScreenLineId, textNodesByScreenLineId + }) } return this.linesVnode @@ -2661,6 +2614,149 @@ class LinesTileComponent { } } +class LinesComponent { + constructor (props) { + const { + width, height, tileStartRow, tileEndRow, renderedStartRow, + screenLines, lineDecorations, + displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId + } = this.props = props + + this.element = document.createElement('div') + this.element.style.position = 'absolute' + this.element.style.contain = 'strict' + this.element.style.height = height + 'px' + this.element.style.width = width + 'px' + + this.lineComponents = [] + for (let row = tileStartRow; row < tileEndRow; row++) { + const i = row - renderedStartRow + const screenLine = screenLines[i] + if (!screenLine) break + + const component = new LineComponent({ + screenLine, + lineDecoration: lineDecorations[i], + displayLayer, + lineNodesByScreenLineId, + textNodesByScreenLineId + }) + this.element.appendChild(component.element) + this.lineComponents.push(component) + } + } + + destroy () { + for (let i = 0; i < this.lineComponents.length; i++) { + this.lineComponents[i].destroy() + } + } + + update (props) { + var { + width, height, tileStartRow, tileEndRow, renderedStartRow, + screenLines, lineDecorations, + displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId + } = props + + if (this.props.width !== width) { + this.element.style.width = width + 'px' + } + + if (this.props.height !== height) { + this.element.style.height = height + 'px' + } + + var oldScreenLines = this.props.screenLines + var newScreenLines = screenLines + var oldScreenLinesEndIndex = this.props.tileEndRow - this.props.renderedStartRow + var newScreenLinesEndIndex = tileEndRow - renderedStartRow + var oldScreenLineIndex = this.props.tileStartRow - this.props.renderedStartRow + var newScreenLineIndex = tileStartRow - renderedStartRow + var lineComponentIndex = 0 + + while (oldScreenLineIndex < oldScreenLinesEndIndex || newScreenLineIndex < newScreenLinesEndIndex) { + var oldScreenLine = oldScreenLines[oldScreenLineIndex] + var newScreenLine = newScreenLines[newScreenLineIndex] + + if (oldScreenLineIndex >= oldScreenLinesEndIndex) { + var newScreenLineComponent = new LineComponent({ + screenLine: newScreenLine, + lineDecoration: lineDecorations[newScreenLineIndex], + displayLayer, + lineNodesByScreenLineId, + textNodesByScreenLineId + }) + this.element.appendChild(newScreenLineComponent.element) + this.lineComponents.push(newScreenLineComponent) + + newScreenLineIndex++ + lineComponentIndex++ + } else if (newScreenLineIndex >= newScreenLinesEndIndex) { + this.lineComponents[lineComponentIndex].destroy() + this.lineComponents.splice(lineComponentIndex, 1) + + oldScreenLineIndex++ + } else if (oldScreenLine === newScreenLine) { + var lineComponent = this.lineComponents[lineComponentIndex] + lineComponent.update({lineDecoration: lineDecorations[newScreenLineIndex]}) + + oldScreenLineIndex++ + newScreenLineIndex++ + lineComponentIndex++ + } else { + var oldScreenLineIndexInNewScreenLines = newScreenLines.indexOf(oldScreenLine) + var newScreenLineIndexInOldScreenLines = oldScreenLines.indexOf(newScreenLine) + if (newScreenLineIndex < oldScreenLineIndexInNewScreenLines && oldScreenLineIndexInNewScreenLines < newScreenLinesEndIndex) { + var newScreenLineComponents = [] + while (newScreenLineIndex < oldScreenLineIndexInNewScreenLines) { + var oldScreenLineComponent = this.lineComponents[lineComponentIndex] + var newScreenLineComponent = new LineComponent({ + screenLine: newScreenLines[newScreenLineIndex], + lineDecoration: lineDecorations[newScreenLineIndex], + displayLayer, + lineNodesByScreenLineId, + textNodesByScreenLineId + }) + this.element.insertBefore(newScreenLineComponent.element, oldScreenLineComponent.element) + newScreenLineComponents.push(newScreenLineComponent) + + newScreenLineIndex++ + } + + this.lineComponents.splice(lineComponentIndex, 0, ...newScreenLineComponents) + lineComponentIndex = lineComponentIndex + newScreenLineComponents.length + } else if (oldScreenLineIndex < newScreenLineIndexInOldScreenLines && newScreenLineIndexInOldScreenLines < oldScreenLinesEndIndex) { + while (oldScreenLineIndex < newScreenLineIndexInOldScreenLines) { + this.lineComponents[lineComponentIndex].destroy() + this.lineComponents.splice(lineComponentIndex, 1) + + oldScreenLineIndex++ + } + } else { + var oldScreenLineComponent = this.lineComponents[lineComponentIndex] + var newScreenLineComponent = new LineComponent({ + screenLine: newScreenLines[newScreenLineIndex], + lineDecoration: lineDecorations[newScreenLineIndex], + displayLayer, + lineNodesByScreenLineId, + textNodesByScreenLineId + }) + this.element.insertBefore(newScreenLineComponent.element, oldScreenLineComponent.element) + oldScreenLineComponent.destroy() + this.lineComponents[lineComponentIndex] = newScreenLineComponent + + oldScreenLineIndex++ + newScreenLineIndex++ + lineComponentIndex++ + } + } + } + + this.props = props + } +} + class LineComponent { constructor (props) { const {displayLayer, screenLine, lineDecoration, lineNodesByScreenLineId, textNodesByScreenLineId} = props @@ -2714,7 +2810,7 @@ class LineComponent { update (newProps) { if (this.props.lineDecoration !== newProps.lineDecoration) { - this.props = newProps + this.props.lineDecoration = newProps.lineDecoration this.element.className = this.buildClassName() } } @@ -2725,6 +2821,8 @@ class LineComponent { lineNodesByScreenLineId.delete(screenLine.id) textNodesByScreenLineId.delete(screenLine.id) } + + this.element.remove() } buildClassName () { From 7474b4b6784b36fc403dffb222f0d1f35e1b498d Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Sat, 8 Apr 2017 13:52:56 +0200 Subject: [PATCH 198/403] Integrate block decorations in the custom lines rendering routine --- spec/text-editor-component-spec.js | 31 +++--- src/text-editor-component.js | 150 ++++++++++++++++++++++------- 2 files changed, 132 insertions(+), 49 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 0537b78f7a9..4ed60c1abd0 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1,4 +1,4 @@ -const {it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise} = require('./async-spec-helpers') +const {it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise, timeoutPromise} = require('./async-spec-helpers') const TextEditorComponent = require('../src/text-editor-component') const TextEditor = require('../src/text-editor') @@ -1268,7 +1268,7 @@ describe('TextEditorComponent', () => { // move decoration2 and decoration3 decoration2.getMarker().setHeadScreenPosition([1, 0]) - decoration3.getMarker().setHeadScreenPosition([3, 0]) + decoration3.getMarker().setHeadScreenPosition([0, 0]) await component.getNextUpdatePromise() expect(component.getRenderedStartRow()).toBe(0) expect(component.getRenderedEndRow()).toBe(6) @@ -1278,24 +1278,23 @@ describe('TextEditorComponent', () => { getElementHeight(item4) + getElementHeight(item5) + getElementHeight(item6) ) expect(tileNodeForScreenRow(component, 0).offsetHeight).toBe( - 3 * component.getLineHeight() + getElementHeight(item2) + 3 * component.getLineHeight() + getElementHeight(item2) + getElementHeight(item3) ) expect(tileNodeForScreenRow(component, 3).offsetHeight).toBe( - 3 * component.getLineHeight() + getElementHeight(item3) + 3 * component.getLineHeight() ) expect(element.querySelectorAll('.line').length).toBe(6) expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) expect(item3.previousSibling).toBeNull() - expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 3)) + expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0)) expect(element.contains(item4)).toBe(false) expect(element.contains(item5)).toBe(false) expect(element.contains(item6)).toBe(false) // change the text - editor.setCursorScreenPosition([0, 5]) - editor.insertNewline() + editor.getBuffer().setTextInRange([[0, 5], [0, 5]], '\n\n') await component.getNextUpdatePromise() expect(component.getRenderedStartRow()).toBe(0) expect(component.getRenderedEndRow()).toBe(6) @@ -1305,17 +1304,17 @@ describe('TextEditorComponent', () => { getElementHeight(item4) + getElementHeight(item5) + getElementHeight(item6) ) expect(tileNodeForScreenRow(component, 0).offsetHeight).toBe( - 3 * component.getLineHeight() + getElementHeight(item2) + 3 * component.getLineHeight() + getElementHeight(item3) ) expect(tileNodeForScreenRow(component, 3).offsetHeight).toBe( - 3 * component.getLineHeight() + getElementHeight(item3) + 3 * component.getLineHeight() + getElementHeight(item2) ) expect(element.querySelectorAll('.line').length).toBe(6) expect(element.contains(item1)).toBe(false) - expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 1)) - expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 2)) - expect(item3.previousSibling).toBe(lineNodeForScreenRow(component, 3)) - expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 4)) + expect(item2.previousSibling).toBeNull() + expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 3)) + expect(item3.previousSibling).toBeNull() + expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0)) expect(element.contains(item4)).toBe(false) expect(element.contains(item5)).toBe(false) expect(element.contains(item6)).toBe(false) @@ -1331,17 +1330,17 @@ describe('TextEditorComponent', () => { getElementHeight(item4) + getElementHeight(item5) + getElementHeight(item6) ) expect(tileNodeForScreenRow(component, 0).offsetHeight).toBe( - 3 * component.getLineHeight() + getElementHeight(item2) + 3 * component.getLineHeight() + getElementHeight(item2) + getElementHeight(item3) ) expect(tileNodeForScreenRow(component, 3).offsetHeight).toBe( - 3 * component.getLineHeight() + getElementHeight(item3) + 3 * component.getLineHeight() ) expect(element.querySelectorAll('.line').length).toBe(6) expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) expect(item3.previousSibling).toBeNull() - expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 3)) + expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0)) expect(element.contains(item4)).toBe(false) expect(element.contains(item5)).toBe(false) expect(element.contains(item6)).toBe(false) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 28562fb7f11..59593b88252 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -480,8 +480,6 @@ class TextEditorComponent { const tileHeight = this.pixelPositionBeforeBlocksForRow(tileEndRow) - this.pixelPositionBeforeBlocksForRow(tileStartRow) const tileIndex = this.tileIndexForTileStartRow(tileStartRow) - const highlightDecorations = this.decorationsToRender.highlights.get(tileStartRow) - tileNodes[tileIndex] = $(LinesTileComponent, { key: tileIndex, measuredContent: this.measuredContent, @@ -493,8 +491,8 @@ class TextEditorComponent { tileStartRow, tileEndRow, screenLines: this.renderedScreenLines, lineDecorations: this.decorationsToRender.lines, - blockDecorations: this.decorationsToRender.blocks, - highlightDecorations, + blockDecorations: this.decorationsToRender.blocks.get(tileStartRow), + highlightDecorations: this.decorationsToRender.highlights.get(tileStartRow), displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId @@ -946,12 +944,21 @@ class TextEditorComponent { addBlockDecorationToRender (decoration, screenRange, reversed) { const screenPosition = reversed ? screenRange.start : screenRange.end - let rowDecorations = this.decorationsToRender.blocks.get(screenPosition.row) - if (rowDecorations == null) { - rowDecorations = [] - this.decorationsToRender.blocks.set(screenPosition.row, rowDecorations) + const tileStartRow = this.tileStartRowForRow(screenPosition.row) + const screenLine = this.renderedScreenLines[screenPosition.row - this.getRenderedStartRow()] + + let decorationsByScreenLine = this.decorationsToRender.blocks.get(tileStartRow) + if (!decorationsByScreenLine) { + decorationsByScreenLine = new Map() + this.decorationsToRender.blocks.set(tileStartRow, decorationsByScreenLine) + } + + let decorations = decorationsByScreenLine.get(screenLine.id) + if (!decorations) { + decorations = [] + decorationsByScreenLine.set(screenLine.id, decorations) } - rowDecorations.push(decoration) + decorations.push(decoration) } updateAbsolutePositionedDecorations () { @@ -2590,25 +2597,31 @@ class LinesTileComponent { } } - if (oldProps.blockDecorations.size !== newProps.blockDecorations.size) return true + if (oldProps.blockDecorations && newProps.blockDecorations) { + if (oldProps.blockDecorations.size !== newProps.blockDecorations.size) return true - let blockDecorationsChanged = false + let blockDecorationsChanged = false - oldProps.blockDecorations.forEach((oldDecorations, row) => { - if (!blockDecorationsChanged) { - const newDecorations = newProps.blockDecorations.get(row) - blockDecorationsChanged = (newDecorations == null || !arraysEqual(oldDecorations, newDecorations)) - } - }) - if (blockDecorationsChanged) return true + oldProps.blockDecorations.forEach((oldDecorations, screenLineId) => { + if (!blockDecorationsChanged) { + const newDecorations = newProps.blockDecorations.get(screenLineId) + blockDecorationsChanged = (newDecorations == null || !arraysEqual(oldDecorations, newDecorations)) + } + }) + if (blockDecorationsChanged) return true - newProps.blockDecorations.forEach((newDecorations, row) => { - if (!blockDecorationsChanged) { - const oldDecorations = oldProps.blockDecorations.get(row) - blockDecorationsChanged = (oldDecorations == null) - } - }) - if (blockDecorationsChanged) return true + newProps.blockDecorations.forEach((newDecorations, screenLineId) => { + if (!blockDecorationsChanged) { + const oldDecorations = oldProps.blockDecorations.get(screenLineId) + blockDecorationsChanged = (oldDecorations == null) + } + }) + if (blockDecorationsChanged) return true + } else if (oldProps.blockDecorations) { + return true + } else if (newProps.blockDecorations) { + return true + } return false } @@ -2616,11 +2629,12 @@ class LinesTileComponent { class LinesComponent { constructor (props) { + this.props = {} const { width, height, tileStartRow, tileEndRow, renderedStartRow, screenLines, lineDecorations, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId - } = this.props = props + } = props this.element = document.createElement('div') this.element.style.position = 'absolute' @@ -2644,6 +2658,8 @@ class LinesComponent { this.element.appendChild(component.element) this.lineComponents.push(component) } + this.updateBlockDecorations(props) + this.props = props } destroy () { @@ -2653,11 +2669,7 @@ class LinesComponent { } update (props) { - var { - width, height, tileStartRow, tileEndRow, renderedStartRow, - screenLines, lineDecorations, - displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId - } = props + var {width, height} = props if (this.props.width !== width) { this.element.style.width = width + 'px' @@ -2667,6 +2679,19 @@ class LinesComponent { this.element.style.height = height + 'px' } + this.updateLines(props) + this.updateBlockDecorations(props) + + this.props = props + } + + updateLines (props) { + var { + tileStartRow, tileEndRow, renderedStartRow, + screenLines, lineDecorations, + displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId + } = props + var oldScreenLines = this.props.screenLines var newScreenLines = screenLines var oldScreenLinesEndIndex = this.props.tileEndRow - this.props.renderedStartRow @@ -2718,7 +2743,7 @@ class LinesComponent { lineNodesByScreenLineId, textNodesByScreenLineId }) - this.element.insertBefore(newScreenLineComponent.element, oldScreenLineComponent.element) + this.element.insertBefore(newScreenLineComponent.element, this.getFirstElementForScreenLine(oldScreenLine)) newScreenLineComponents.push(newScreenLineComponent) newScreenLineIndex++ @@ -2752,8 +2777,67 @@ class LinesComponent { } } } + } - this.props = props + getFirstElementForScreenLine (screenLine) { + var blockDecorations = this.props.blockDecorations ? this.props.blockDecorations.get(screenLine.id) : null + if (blockDecorations) { + var blockDecorationElementsBeforeOldScreenLine = [] + for (var i = 0; i < blockDecorations.length; i++) { + var decoration = blockDecorations[i] + if (decoration.position !== 'after') { + blockDecorationElementsBeforeOldScreenLine.push( + TextEditor.viewForItem(decoration.item) + ) + } + } + + for (var i = 0; i < blockDecorationElementsBeforeOldScreenLine.length; i++) { + var blockDecorationElement = blockDecorationElementsBeforeOldScreenLine[i] + if (!blockDecorationElementsBeforeOldScreenLine.includes(blockDecorationElement.previousSibling)) { + return blockDecorationElement + } + } + } + + return this.props.lineNodesByScreenLineId.get(screenLine.id) + } + + updateBlockDecorations (props) { + var {blockDecorations, lineNodesByScreenLineId} = props + + if (this.props.blockDecorations) { + this.props.blockDecorations.forEach((oldDecorations, screenLineId) => { + var newDecorations = props.blockDecorations ? props.blockDecorations.get(screenLineId) : null + for (var i = 0; i < oldDecorations.length; i++) { + var oldDecoration = oldDecorations[i] + if (newDecorations && newDecorations.includes(oldDecoration)) continue + + var element = TextEditor.viewForItem(oldDecoration.item) + if (element.parentElement !== this.element) continue + + element.remove() + } + }) + } + + if (props.blockDecorations) { + props.blockDecorations.forEach((newDecorations, screenLineId) => { + var oldDecorations = this.props.blockDecorations ? this.props.blockDecorations.get(screenLineId) : null + for (var i = 0; i < newDecorations.length; i++) { + var newDecoration = newDecorations[i] + if (oldDecorations && oldDecorations.includes(newDecoration)) continue + + var element = TextEditor.viewForItem(newDecoration.item) + var lineNode = lineNodesByScreenLineId.get(screenLineId) + if (newDecoration.position === 'after') { + this.element.insertBefore(element, lineNode.nextSibling) + } else { + this.element.insertBefore(element, lineNode) + } + } + }) + } } } From b264d4764ac9ba7039770538e838eb97bb0d3c23 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 10 Apr 2017 15:45:45 +0200 Subject: [PATCH 199/403] Align line number nodes with line nodes --- spec/text-editor-component-spec.js | 108 +++++++++++++++++------------ src/text-editor-component.js | 50 ++++++++++++- 2 files changed, 111 insertions(+), 47 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 4ed60c1abd0..c7cedb86307 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1171,12 +1171,11 @@ describe('TextEditorComponent', () => { editor.getScreenLineCount() * component.getLineHeight() + getElementHeight(item1) + getElementHeight(item2) ) - expect(tileNodeForScreenRow(component, 0).offsetHeight).toBe( - 3 * component.getLineHeight() + getElementHeight(item1) + getElementHeight(item2) - ) - expect(tileNodeForScreenRow(component, 3).offsetHeight).toBe( - 3 * component.getLineHeight() - ) + assertTilesAreSizedAndPositionedCorrectly(component, [ + {tileStartRow: 0, height: 3 * component.getLineHeight() + getElementHeight(item1) + getElementHeight(item2)}, + {tileStartRow: 3, height: 3 * component.getLineHeight()} + ]) + assertLinesAreAlignedWithLineNumbers(component) expect(element.querySelectorAll('.line').length).toBe(6) expect(item1.previousSibling).toBeNull() expect(item1.nextSibling).toBe(lineNodeForScreenRow(component, 0)) @@ -1196,12 +1195,11 @@ describe('TextEditorComponent', () => { getElementHeight(item1) + getElementHeight(item2) + getElementHeight(item3) + getElementHeight(item4) + getElementHeight(item5) + getElementHeight(item6) ) - expect(tileNodeForScreenRow(component, 0).offsetHeight).toBe( - 3 * component.getLineHeight() + getElementHeight(item1) + getElementHeight(item2) - ) - expect(tileNodeForScreenRow(component, 3).offsetHeight).toBe( - 3 * component.getLineHeight() + getElementHeight(item3) - ) + assertTilesAreSizedAndPositionedCorrectly(component, [ + {tileStartRow: 0, height: 3 * component.getLineHeight() + getElementHeight(item1) + getElementHeight(item2)}, + {tileStartRow: 3, height: 3 * component.getLineHeight() + getElementHeight(item3)} + ]) + assertLinesAreAlignedWithLineNumbers(component) expect(element.querySelectorAll('.line').length).toBe(6) expect(item1.previousSibling).toBeNull() expect(item1.nextSibling).toBe(lineNodeForScreenRow(component, 0)) @@ -1222,12 +1220,11 @@ describe('TextEditorComponent', () => { getElementHeight(item1) + getElementHeight(item2) + getElementHeight(item3) + getElementHeight(item4) + getElementHeight(item5) + getElementHeight(item6) ) - expect(tileNodeForScreenRow(component, 3).offsetHeight).toBe( - 3 * component.getLineHeight() + getElementHeight(item3) - ) - expect(tileNodeForScreenRow(component, 6).offsetHeight).toBe( - 3 * component.getLineHeight() + getElementHeight(item4) + getElementHeight(item5) - ) + assertTilesAreSizedAndPositionedCorrectly(component, [ + {tileStartRow: 3, height: 3 * component.getLineHeight() + getElementHeight(item3)}, + {tileStartRow: 6, height: 3 * component.getLineHeight() + getElementHeight(item4) + getElementHeight(item5)} + ]) + assertLinesAreAlignedWithLineNumbers(component) expect(element.querySelectorAll('.line').length).toBe(6) expect(element.contains(item1)).toBe(false) expect(element.contains(item2)).toBe(false) @@ -1250,12 +1247,11 @@ describe('TextEditorComponent', () => { getElementHeight(item2) + getElementHeight(item3) + getElementHeight(item4) + getElementHeight(item5) + getElementHeight(item6) ) - expect(tileNodeForScreenRow(component, 0).offsetHeight).toBe( - 3 * component.getLineHeight() + getElementHeight(item2) - ) - expect(tileNodeForScreenRow(component, 3).offsetHeight).toBe( - 3 * component.getLineHeight() + getElementHeight(item3) - ) + assertTilesAreSizedAndPositionedCorrectly(component, [ + {tileStartRow: 0, height: 3 * component.getLineHeight() + getElementHeight(item2)}, + {tileStartRow: 3, height: 3 * component.getLineHeight() + getElementHeight(item3)} + ]) + assertLinesAreAlignedWithLineNumbers(component) expect(element.querySelectorAll('.line').length).toBe(6) expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 1)) @@ -1277,12 +1273,11 @@ describe('TextEditorComponent', () => { getElementHeight(item2) + getElementHeight(item3) + getElementHeight(item4) + getElementHeight(item5) + getElementHeight(item6) ) - expect(tileNodeForScreenRow(component, 0).offsetHeight).toBe( - 3 * component.getLineHeight() + getElementHeight(item2) + getElementHeight(item3) - ) - expect(tileNodeForScreenRow(component, 3).offsetHeight).toBe( - 3 * component.getLineHeight() - ) + assertTilesAreSizedAndPositionedCorrectly(component, [ + {tileStartRow: 0, height: 3 * component.getLineHeight() + getElementHeight(item2) + getElementHeight(item3)}, + {tileStartRow: 3, height: 3 * component.getLineHeight()} + ]) + assertLinesAreAlignedWithLineNumbers(component) expect(element.querySelectorAll('.line').length).toBe(6) expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) @@ -1303,12 +1298,11 @@ describe('TextEditorComponent', () => { getElementHeight(item2) + getElementHeight(item3) + getElementHeight(item4) + getElementHeight(item5) + getElementHeight(item6) ) - expect(tileNodeForScreenRow(component, 0).offsetHeight).toBe( - 3 * component.getLineHeight() + getElementHeight(item3) - ) - expect(tileNodeForScreenRow(component, 3).offsetHeight).toBe( - 3 * component.getLineHeight() + getElementHeight(item2) - ) + assertTilesAreSizedAndPositionedCorrectly(component, [ + {tileStartRow: 0, height: 3 * component.getLineHeight() + getElementHeight(item3)}, + {tileStartRow: 3, height: 3 * component.getLineHeight() + getElementHeight(item2)} + ]) + assertLinesAreAlignedWithLineNumbers(component) expect(element.querySelectorAll('.line').length).toBe(6) expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBeNull() @@ -1329,12 +1323,11 @@ describe('TextEditorComponent', () => { getElementHeight(item2) + getElementHeight(item3) + getElementHeight(item4) + getElementHeight(item5) + getElementHeight(item6) ) - expect(tileNodeForScreenRow(component, 0).offsetHeight).toBe( - 3 * component.getLineHeight() + getElementHeight(item2) + getElementHeight(item3) - ) - expect(tileNodeForScreenRow(component, 3).offsetHeight).toBe( - 3 * component.getLineHeight() - ) + assertTilesAreSizedAndPositionedCorrectly(component, [ + {tileStartRow: 0, height: 3 * component.getLineHeight() + getElementHeight(item2) + getElementHeight(item3)}, + {tileStartRow: 3, height: 3 * component.getLineHeight()} + ]) + assertLinesAreAlignedWithLineNumbers(component) expect(element.querySelectorAll('.line').length).toBe(6) expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) @@ -1355,6 +1348,33 @@ describe('TextEditorComponent', () => { const decoration = editor.decorateMarker(marker, {type: 'block', item, position}) return {item, decoration} } + + function assertTilesAreSizedAndPositionedCorrectly (component, tiles) { + let top = 0 + for (let tile of tiles) { + const linesTileElement = lineNodeForScreenRow(component, tile.tileStartRow).parentElement + const linesTileBoundingRect = linesTileElement.getBoundingClientRect() + expect(linesTileBoundingRect.height).toBe(tile.height) + expect(linesTileBoundingRect.top).toBe(top) + + const lineNumbersTileElement = lineNumberNodeForScreenRow(component, tile.tileStartRow).parentElement + const lineNumbersTileBoundingRect = lineNumbersTileElement.getBoundingClientRect() + expect(lineNumbersTileBoundingRect.height).toBe(tile.height) + expect(lineNumbersTileBoundingRect.top).toBe(top) + + top += tile.height + } + } + + function assertLinesAreAlignedWithLineNumbers (component) { + const startRow = component.getRenderedStartRow() + const endRow = component.getRenderedEndRow() + for (let row = startRow; row < endRow; row++) { + const lineNode = lineNodeForScreenRow(component, row) + const lineNumberNode = lineNumberNodeForScreenRow(component, row) + expect(lineNumberNode.getBoundingClientRect().top).toBe(lineNode.getBoundingClientRect().top) + } + } }) describe('mouse input', () => { @@ -2125,10 +2145,6 @@ function lineNumberNodeForScreenRow (component, row) { return gutterElement.children[tileIndex + 1].children[row - tileStartRow] } -function tileNodeForScreenRow (component, row) { - return lineNodeForScreenRow(component, row).parentElement -} - function lineNodeForScreenRow (component, row) { const renderedScreenLine = component.renderedScreenLineForRow(row) return component.lineNodesByScreenLineId.get(renderedScreenLine.id) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 59593b88252..e2451de1f89 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -383,6 +383,7 @@ class TextEditorComponent { numbers: numbers, foldableFlags: foldableFlags, decorations: this.decorationsToRender.lineNumbers, + blockDecorations: this.decorationsToRender.blocks, height: this.getScrollHeight(), width: this.getLineNumberGutterWidth(), lineHeight: this.getLineHeight(), @@ -2335,7 +2336,17 @@ class LineNumberGutterComponent { if (number === -1) number = '•' number = NBSP_CHARACTER.repeat(maxDigits - number.length) + number - tileChildren[row - tileStartRow] = $.div({key, className}, + let lineNumberProps = {key, className} + + if (row === 0 || i > 0) { + let currentRowTop = parentComponent.pixelPositionAfterBlocksForRow(row) + let previousRowBottom = parentComponent.pixelPositionAfterBlocksForRow(row - 1) + lineHeight + if (currentRowTop > previousRowBottom) { + lineNumberProps.style = {marginTop: (currentRowTop - previousRowBottom) + 'px'} + } + } + + tileChildren[row - tileStartRow] = $.div(lineNumberProps, number, $.div({className: 'icon-right'}) ) @@ -2394,6 +2405,43 @@ class LineNumberGutterComponent { if (!arraysEqual(oldProps.numbers, newProps.numbers)) return true if (!arraysEqual(oldProps.foldableFlags, newProps.foldableFlags)) return true if (!arraysEqual(oldProps.decorations, newProps.decorations)) return true + + let oldTileStartRow = oldProps.startRow + let newTileStartRow = newProps.startRow + while (oldTileStartRow < oldProps.endRow || newTileStartRow < newProps.endRow) { + let oldTileBlockDecorations = oldProps.blockDecorations.get(oldTileStartRow) + let newTileBlockDecorations = newProps.blockDecorations.get(newTileStartRow) + + if (oldTileBlockDecorations && newTileBlockDecorations) { + if (oldTileBlockDecorations.size !== newTileBlockDecorations.size) return true + + let blockDecorationsChanged = false + + oldTileBlockDecorations.forEach((oldDecorations, screenLineId) => { + if (!blockDecorationsChanged) { + const newDecorations = newTileBlockDecorations.get(screenLineId) + blockDecorationsChanged = (newDecorations == null || !arraysEqual(oldDecorations, newDecorations)) + } + }) + if (blockDecorationsChanged) return true + + newTileBlockDecorations.forEach((newDecorations, screenLineId) => { + if (!blockDecorationsChanged) { + const oldDecorations = oldTileBlockDecorations.get(screenLineId) + blockDecorationsChanged = (oldDecorations == null) + } + }) + if (blockDecorationsChanged) return true + } else if (oldTileBlockDecorations) { + return true + } else if (newTileBlockDecorations) { + return true + } + + oldTileStartRow += oldProps.rowsPerTile + newTileStartRow += newProps.rowsPerTile + } + return false } From bc8b548d1a58c5b6715d391453a534fc2d729474 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 10 Apr 2017 17:11:24 +0200 Subject: [PATCH 200/403] Add TextEditorElement.prototype.invalidateBlockDecorationDimensions --- spec/text-editor-component-spec.js | 28 ++++++++++++++++++++++++++++ src/text-editor-component.js | 30 +++++++++++++++++++++++++++--- src/text-editor-element.js | 12 ++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index c7cedb86307..d00400dc0f2 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1337,6 +1337,34 @@ describe('TextEditorComponent', () => { expect(element.contains(item4)).toBe(false) expect(element.contains(item5)).toBe(false) expect(element.contains(item6)).toBe(false) + + // invalidate decorations + item2.style.height = '20px' + item3.style.height = '22px' + component.invalidateBlockDecorationDimensions(decoration2) + component.invalidateBlockDecorationDimensions(decoration3) + await component.getNextUpdatePromise() + expect(component.getRenderedStartRow()).toBe(0) + expect(component.getRenderedEndRow()).toBe(6) + expect(component.getScrollHeight()).toBe( + editor.getScreenLineCount() * component.getLineHeight() + + getElementHeight(item2) + getElementHeight(item3) + + getElementHeight(item4) + getElementHeight(item5) + getElementHeight(item6) + ) + assertTilesAreSizedAndPositionedCorrectly(component, [ + {tileStartRow: 0, height: 3 * component.getLineHeight() + getElementHeight(item2) + getElementHeight(item3)}, + {tileStartRow: 3, height: 3 * component.getLineHeight()} + ]) + assertLinesAreAlignedWithLineNumbers(component) + expect(element.querySelectorAll('.line').length).toBe(6) + expect(element.contains(item1)).toBe(false) + expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) + expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) + expect(item3.previousSibling).toBeNull() + expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0)) + expect(element.contains(item4)).toBe(false) + expect(element.contains(item5)).toBe(false) + expect(element.contains(item6)).toBe(false) }) function createBlockDecorationAtScreenRow(editor, screenRow, {height, margin, position}) { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index e2451de1f89..7ba75573f9d 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -184,22 +184,41 @@ class TextEditorComponent { measureBlockDecorations () { if (this.blockDecorationsToMeasure.size > 0) { const {blockDecorationMeasurementArea} = this.refs + const sentinelElements = new Set() blockDecorationMeasurementArea.appendChild(document.createElement('div')) this.blockDecorationsToMeasure.forEach((decoration) => { const {item} = decoration.getProperties() - blockDecorationMeasurementArea.appendChild(TextEditor.viewForItem(item)) - blockDecorationMeasurementArea.appendChild(document.createElement('div')) + const decorationElement = TextEditor.viewForItem(item) + if (document.contains(decorationElement)) { + const parentElement = decorationElement.parentElement + + if (!decorationElement.previousSibling) { + const sentinelElement = document.createElement('div') + parentElement.insertBefore(sentinelElement, decorationElement) + sentinelElements.add(sentinelElement) + } + + if (!decorationElement.nextSibling) { + const sentinelElement = document.createElement('div') + parentElement.appendChild(sentinelElement) + sentinelElements.add(sentinelElement) + } + } else { + blockDecorationMeasurementArea.appendChild(decorationElement) + blockDecorationMeasurementArea.appendChild(document.createElement('div')) + } }) this.blockDecorationsToMeasure.forEach((decoration) => { const {item} = decoration.getProperties() const decorationElement = TextEditor.viewForItem(item) const {previousSibling, nextSibling} = decorationElement - const height = nextSibling.offsetTop - previousSibling.offsetTop + const height = nextSibling.getBoundingClientRect().top - previousSibling.getBoundingClientRect().bottom this.lineTopIndex.resizeBlock(decoration, height) }) + sentinelElements.forEach((sentinelElement) => sentinelElement.remove()) while (blockDecorationMeasurementArea.firstChild) { blockDecorationMeasurementArea.firstChild.remove() } @@ -1918,6 +1937,11 @@ class TextEditorComponent { }) } + invalidateBlockDecorationDimensions (decoration) { + this.blockDecorationsToMeasure.add(decoration) + this.scheduleUpdate() + } + spliceLineTopIndex (startRow, oldExtent, newExtent) { const invalidatedBlockDecorations = this.lineTopIndex.splice(startRow, oldExtent, newExtent) invalidatedBlockDecorations.forEach((decoration) => { diff --git a/src/text-editor-element.js b/src/text-editor-element.js index eb64e5fa748..b1ea45dd269 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -66,6 +66,18 @@ class TextEditorElement extends HTMLElement { if (this.component) this.component.updatedSynchronously = updatedSynchronously return updatedSynchronously } + + // Experimental: Invalidate the passed block {Decoration}'s dimensions, + // forcing them to be recalculated and the surrounding content to be adjusted + // on the next animation frame. + // + // * {blockDecoration} A {Decoration} representing the block decoration you + // want to update the dimensions of. + invalidateBlockDecorationDimensions () { + if (this.component) { + this.component.invalidateBlockDecorationDimensions(...arguments) + } + } } module.exports = From 8aae3ab1ae6bfe79b12cb6f4a26d5073dd4cb2eb Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 10 Apr 2017 16:10:00 -0600 Subject: [PATCH 201/403] Hide cursors with non-empty selection if showCursorsOnSelection is false Also, remove some barely used public APIs around cursor visibility that don't make much sense and are not ideal for performance. We don't want to subscribe to the visibility of each cursor. --- spec/text-editor-component-spec.js | 33 ++++++++++++++++++++++++ src/cursor.coffee | 40 +----------------------------- src/selection.coffee | 2 -- src/text-editor-component.js | 1 + src/text-editor.coffee | 2 +- 5 files changed, 36 insertions(+), 42 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index d00400dc0f2..398ad715588 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -254,6 +254,39 @@ describe('TextEditorComponent', () => { expect(cursorNodes.length).toBe(0) }) + it('hides cursors with non-empty selections when showCursorOnSelection is false', async () => { + const {component, element, editor} = buildComponent() + editor.setSelectedScreenRanges([ + [[0, 0], [0, 3]], + [[1, 0], [1, 0]] + ]) + await component.getNextUpdatePromise() + { + const cursorNodes = Array.from(element.querySelectorAll('.cursor')) + expect(cursorNodes.length).toBe(2) + verifyCursorPosition(component, cursorNodes[0], 0, 3) + verifyCursorPosition(component, cursorNodes[1], 1, 0) + } + + editor.update({showCursorOnSelection: false}) + await component.getNextUpdatePromise() + { + const cursorNodes = Array.from(element.querySelectorAll('.cursor')) + expect(cursorNodes.length).toBe(1) + verifyCursorPosition(component, cursorNodes[0], 1, 0) + } + + editor.setSelectedScreenRanges([ + [[0, 0], [0, 3]], + [[1, 0], [1, 4]] + ]) + await component.getNextUpdatePromise() + { + const cursorNodes = Array.from(element.querySelectorAll('.cursor')) + expect(cursorNodes.length).toBe(0) + } + }) + it('blinks cursors when the editor is focused and the cursors are not moving', async () => { assertDocumentFocused() const {component, element, editor} = buildComponent() diff --git a/src/cursor.coffee b/src/cursor.coffee index 47e8c0594c6..184e6ad43ab 100644 --- a/src/cursor.coffee +++ b/src/cursor.coffee @@ -12,20 +12,14 @@ EmptyLineRegExp = /(\r\n[\t ]*\r\n)|(\n[\t ]*\n)/g # of a {DisplayMarker}. module.exports = class Cursor extends Model - showCursorOnSelection: null screenPosition: null bufferPosition: null goalColumn: null - visible: true # Instantiated by a {TextEditor} - constructor: ({@editor, @marker, @showCursorOnSelection, id}) -> + constructor: ({@editor, @marker, id}) -> @emitter = new Emitter - - @showCursorOnSelection ?= true - @assignId(id) - @updateVisibility() destroy: -> @marker.destroy() @@ -57,15 +51,6 @@ class Cursor extends Model onDidDestroy: (callback) -> @emitter.on 'did-destroy', callback - # Public: Calls your `callback` when the cursor's visibility has changed - # - # * `callback` {Function} - # * `visibility` {Boolean} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeVisibility: (callback) -> - @emitter.on 'did-change-visibility', callback - ### Section: Managing Cursor Position ### @@ -568,21 +553,6 @@ class Cursor extends Model Section: Visibility ### - # Public: Sets whether the cursor is visible. - setVisible: (visible) -> - if @visible isnt visible - @visible = visible - @emitter.emit 'did-change-visibility', @visible - - # Public: Returns the visibility of the cursor. - isVisible: -> @visible - - updateVisibility: -> - if @showCursorOnSelection - @setVisible(true) - else - @setVisible(@marker.getBufferRange().isEmpty()) - ### Section: Comparing to another cursor ### @@ -599,9 +569,6 @@ class Cursor extends Model Section: Utilities ### - # Public: Prevents this cursor from causing scrolling. - clearAutoscroll: -> - # Public: Deselects the current selection. clearSelection: (options) -> @selection?.clear(options) @@ -651,11 +618,6 @@ class Cursor extends Model Section: Private ### - setShowCursorOnSelection: (value) -> - if value isnt @showCursorOnSelection - @showCursorOnSelection = value - @updateVisibility() - getNonWordCharacters: -> @editor.getNonWordCharacters(@getScopeDescriptor().getScopesArray()) diff --git a/src/selection.coffee b/src/selection.coffee index 8aa86157efc..935a15b13e6 100644 --- a/src/selection.coffee +++ b/src/selection.coffee @@ -769,8 +769,6 @@ class Selection extends Model {oldHeadScreenPosition, oldTailScreenPosition, newHeadScreenPosition} = e {textChanged} = e - @cursor.updateVisibility() - unless oldHeadScreenPosition.isEqual(newHeadScreenPosition) @cursor.goalColumn = null cursorMovedEvent = { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 7ba75573f9d..c87958f986c 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -920,6 +920,7 @@ class TextEditorComponent { addCursorDecorationToMeasure (marker, screenRange, reversed) { const {model} = this.props + if (!model.getShowCursorOnSelection() && !screenRange.isEmpty()) return const isLastCursor = model.getLastCursor().getMarker() === marker const screenPosition = reversed ? screenRange.start : screenRange.end const {row, column} = screenPosition diff --git a/src/text-editor.coffee b/src/text-editor.coffee index f2c0ab92f38..bf3979a365b 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -379,7 +379,7 @@ class TextEditor extends Model when 'showCursorOnSelection' if value isnt @showCursorOnSelection @showCursorOnSelection = value - cursor.setShowCursorOnSelection(value) for cursor in @getCursors() + @component?.scheduleUpdate() else if param isnt 'ref' and param isnt 'key' From 8103bd687cfd22e82ea56d8b79f20b5c1c9fdf9b Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 11 Apr 2017 14:24:38 +0200 Subject: [PATCH 202/403] Update line number gutter when invalidating a visible block decoration When two or more decorations in the same tile were invalidated but the sum of their height didn't change, we were previously failing to recognize that the line numbers gutter needed to be re-rendered. With this commit, whenever a block decoration is visible and gets invalidated, we will force the line number gutter to always update. --- spec/text-editor-component-spec.js | 8 ++++++-- src/text-editor-component.js | 5 +++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 398ad715588..80269f9c994 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1371,9 +1371,13 @@ describe('TextEditorComponent', () => { expect(element.contains(item5)).toBe(false) expect(element.contains(item6)).toBe(false) - // invalidate decorations - item2.style.height = '20px' + // invalidate decorations. this also tests a case where two decorations in + // the same tile change their height without affecting the tile height nor + // the content height. item3.style.height = '22px' + item3.style.margin = '10px' + item2.style.height = '33px' + item2.style.margin = '0px' component.invalidateBlockDecorationDimensions(decoration2) component.invalidateBlockDecorationDimensions(decoration3) await component.getNextUpdatePromise() diff --git a/src/text-editor-component.js b/src/text-editor-component.js index c87958f986c..1915f93be2d 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -204,6 +204,8 @@ class TextEditorComponent { parentElement.appendChild(sentinelElement) sentinelElements.add(sentinelElement) } + + this.didMeasureVisibleBlockDecoration = true } else { blockDecorationMeasurementArea.appendChild(decorationElement) blockDecorationMeasurementArea.appendChild(document.createElement('div')) @@ -237,6 +239,7 @@ class TextEditorComponent { this.shouldRenderDummyScrollbars = !this.refreshedScrollbarStyle etch.updateSync(this) this.shouldRenderDummyScrollbars = true + this.didMeasureVisibleBlockDecoration = false } measureContentDuringUpdateSync () { @@ -403,6 +406,7 @@ class TextEditorComponent { foldableFlags: foldableFlags, decorations: this.decorationsToRender.lineNumbers, blockDecorations: this.decorationsToRender.blocks, + didMeasureVisibleBlockDecoration: this.didMeasureVisibleBlockDecoration, height: this.getScrollHeight(), width: this.getLineNumberGutterWidth(), lineHeight: this.getLineHeight(), @@ -2426,6 +2430,7 @@ class LineNumberGutterComponent { if (oldProps.endRow !== newProps.endRow) return true if (oldProps.rowsPerTile !== newProps.rowsPerTile) return true if (oldProps.maxDigits !== newProps.maxDigits) return true + if (newProps.didMeasureVisibleBlockDecoration) return true if (!arraysEqual(oldProps.keys, newProps.keys)) return true if (!arraysEqual(oldProps.numbers, newProps.numbers)) return true if (!arraysEqual(oldProps.foldableFlags, newProps.foldableFlags)) return true From 054c133ed47672bf57c3d0accf51886b95d2b72b Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 11 Apr 2017 18:08:11 +0200 Subject: [PATCH 203/403] Remeasure block decorations when editor width changes Signed-off-by: Nathan Sobo --- spec/text-editor-component-spec.js | 68 ++++++++++++++++++++++++++++++ src/text-editor-component.js | 48 +++++++++++++++++---- 2 files changed, 108 insertions(+), 8 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 80269f9c994..9cc66fa2024 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1402,6 +1402,74 @@ describe('TextEditorComponent', () => { expect(element.contains(item4)).toBe(false) expect(element.contains(item5)).toBe(false) expect(element.contains(item6)).toBe(false) + + // make decoration before row 0 as wide as the editor, and insert some text into it so that it wraps. + item3.style.height = '' + item3.style.margin = '' + item3.style.width = '' + item3.style.wordWrap = 'break-word' + const contentWidthInCharacters = Math.floor(component.getScrollContainerClientWidth() / component.getBaseCharacterWidth()) + item3.textContent = 'x'.repeat(contentWidthInCharacters * 2) + component.invalidateBlockDecorationDimensions(decoration3) + await component.getNextUpdatePromise() + + // make the editor wider, so that the decoration doesn't wrap anymore. + component.element.style.width = ( + component.getGutterContainerWidth() + + component.getScrollContainerClientWidth() * 2 + + component.getVerticalScrollbarWidth() + ) + 'px' + await component.getNextUpdatePromise() + expect(component.getRenderedStartRow()).toBe(0) + expect(component.getRenderedEndRow()).toBe(6) + expect(component.getScrollHeight()).toBe( + editor.getScreenLineCount() * component.getLineHeight() + + getElementHeight(item2) + getElementHeight(item3) + + getElementHeight(item4) + getElementHeight(item5) + getElementHeight(item6) + ) + assertTilesAreSizedAndPositionedCorrectly(component, [ + {tileStartRow: 0, height: 3 * component.getLineHeight() + getElementHeight(item2) + getElementHeight(item3)}, + {tileStartRow: 3, height: 3 * component.getLineHeight()} + ]) + assertLinesAreAlignedWithLineNumbers(component) + expect(element.querySelectorAll('.line').length).toBe(6) + expect(element.contains(item1)).toBe(false) + expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) + expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) + expect(item3.previousSibling).toBeNull() + expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0)) + expect(element.contains(item4)).toBe(false) + expect(element.contains(item5)).toBe(false) + expect(element.contains(item6)).toBe(false) + + // make the editor taller and wider and the same time, ensuring the number + // of rendered lines is correct. + setEditorHeightInLines(component, 10) + await setEditorWidthInCharacters(component, 50) + expect(component.getRenderedStartRow()).toBe(0) + expect(component.getRenderedEndRow()).toBe(9) + expect(component.getScrollHeight()).toBe( + editor.getScreenLineCount() * component.getLineHeight() + + getElementHeight(item2) + getElementHeight(item3) + + getElementHeight(item4) + getElementHeight(item5) + getElementHeight(item6) + ) + assertTilesAreSizedAndPositionedCorrectly(component, [ + {tileStartRow: 0, height: 3 * component.getLineHeight() + getElementHeight(item2) + getElementHeight(item3)}, + {tileStartRow: 3, height: 3 * component.getLineHeight()}, + {tileStartRow: 6, height: 3 * component.getLineHeight() + getElementHeight(item4) + getElementHeight(item5)}, + ]) + assertLinesAreAlignedWithLineNumbers(component) + expect(element.querySelectorAll('.line').length).toBe(9) + expect(element.contains(item1)).toBe(false) + expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) + expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) + expect(item3.previousSibling).toBeNull() + expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0)) + expect(item4.previousSibling).toBe(lineNodeForScreenRow(component, 6)) + expect(item4.nextSibling).toBe(lineNodeForScreenRow(component, 7)) + expect(item5.previousSibling).toBe(lineNodeForScreenRow(component, 7)) + expect(item5.nextSibling).toBe(lineNodeForScreenRow(component, 8)) + expect(element.contains(item6)).toBe(false) }) function createBlockDecorationAtScreenRow(editor, screenRow, {height, margin, position}) { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 1915f93be2d..dbfb9f1982c 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -182,6 +182,24 @@ class TextEditorComponent { } measureBlockDecorations () { + if (this.remeasureAllBlockDecorations) { + this.remeasureAllBlockDecorations = false + + const decorations = this.props.model.getDecorations() + for (var i = 0; i < decorations.length; i++) { + const decoration = decorations[i] + if (decoration.getProperties().type === 'block') { + this.blockDecorationsToMeasure.add(decoration) + } + } + + // Update the width of the line tiles to ensure block decorations are + // measured with the most recent width. + if (this.blockDecorationsToMeasure.size > 0) { + this.updateSyncBeforeMeasuringContent() + } + } + if (this.blockDecorationsToMeasure.size > 0) { const {blockDecorationMeasurementArea} = this.refs const sentinelElements = new Set() @@ -1188,7 +1206,13 @@ class TextEditorComponent { } didResize () { - if (this.measureClientContainerDimensions()) { + const clientContainerWidthChanged = this.measureClientContainerWidth() + const clientContainerHeightChanged = this.measureClientContainerHeight() + if (clientContainerWidthChanged || clientContainerHeightChanged) { + if (clientContainerWidthChanged) { + this.remeasureAllBlockDecorations = true + } + this.scheduleUpdate() } } @@ -1646,7 +1670,8 @@ class TextEditorComponent { this.measurements = {} this.measureCharacterDimensions() this.measureGutterDimensions() - this.measureClientContainerDimensions() + this.measureClientContainerHeight() + this.measureClientContainerWidth() this.measureScrollbarDimensions() } @@ -1692,22 +1717,29 @@ class TextEditorComponent { return dimensionsChanged } - measureClientContainerDimensions () { + measureClientContainerHeight () { if (!this.measurements) return false - let dimensionsChanged = false const clientContainerHeight = this.refs.clientContainer.offsetHeight - const clientContainerWidth = this.refs.clientContainer.offsetWidth if (clientContainerHeight !== this.measurements.clientContainerHeight) { this.measurements.clientContainerHeight = clientContainerHeight - dimensionsChanged = true + return true + } else { + return false } + } + + measureClientContainerWidth () { + if (!this.measurements) return false + + const clientContainerWidth = this.refs.clientContainer.offsetWidth if (clientContainerWidth !== this.measurements.clientContainerWidth) { this.measurements.clientContainerWidth = clientContainerWidth this.props.model.setEditorWidthInChars(this.getScrollContainerWidth() / this.getBaseCharacterWidth()) - dimensionsChanged = true + return true + } else { + return false } - return dimensionsChanged } measureScrollbarDimensions () { From b6cd473c1607829f5f95d7a9dd5ba925fdfbd755 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 11 Apr 2017 13:27:18 -0600 Subject: [PATCH 204/403] Fix typo --- src/text-editor-element.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/text-editor-element.js b/src/text-editor-element.js index b1ea45dd269..23a5aeb6ef8 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -34,7 +34,7 @@ class TextEditorElement extends HTMLElement { } onDidChangeScrollTop (callback) { - return this.emitter.on('did-change-scrol-top', callback) + return this.emitter.on('did-change-scroll-top', callback) } getDefaultCharacterWidth () { From 95c895000468ec949606e6024f3eda1ef216d369 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 11 Apr 2017 15:05:31 -0600 Subject: [PATCH 205/403] Re-measure and update rendered content when editor styles change --- spec/text-editor-component-spec.js | 65 +++++++++++++++++++++++++----- src/atom-environment.coffee | 1 + src/text-editor-component.js | 55 +++++++++++++++++-------- src/text-editor.coffee | 4 ++ 4 files changed, 99 insertions(+), 26 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 9cc66fa2024..a23cb5e0094 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -28,23 +28,23 @@ describe('TextEditorComponent', () => { it('renders lines and line numbers for the visible region', async () => { const {component, element, editor} = buildComponent({rowsPerTile: 3, autoHeight: false}) - expect(element.querySelectorAll('.line-number').length).toBe(13 + 1) // +1 for placeholder line number - expect(element.querySelectorAll('.line').length).toBe(13) + expect(element.querySelectorAll('.line-number:not(.dummy)').length).toBe(13) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(13) element.style.height = 4 * component.measurements.lineHeight + 'px' await component.getNextUpdatePromise() - expect(element.querySelectorAll('.line-number').length).toBe(9 + 1) // +1 for placeholder line number - expect(element.querySelectorAll('.line').length).toBe(9) + expect(element.querySelectorAll('.line-number:not(.dummy)').length).toBe(9) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(9) await setScrollTop(component, 5 * component.getLineHeight()) // After scrolling down beyond > 3 rows, the order of line numbers and lines // in the DOM is a bit weird because the first tile is recycled to the bottom // when it is scrolled out of view - expect(Array.from(element.querySelectorAll('.line-number')).slice(1).map(element => element.textContent.trim())).toEqual([ + expect(Array.from(element.querySelectorAll('.line-number:not(.dummy)')).map(element => element.textContent.trim())).toEqual([ '10', '11', '12', '4', '5', '6', '7', '8', '9' ]) - expect(Array.from(element.querySelectorAll('.line')).map(element => element.textContent)).toEqual([ + expect(Array.from(element.querySelectorAll('.line:not(.dummy)')).map(element => element.textContent)).toEqual([ editor.lineTextForScreenRow(9), ' ', // this line is blank in the model, but we render a space to prevent the line from collapsing vertically editor.lineTextForScreenRow(11), @@ -57,10 +57,10 @@ describe('TextEditorComponent', () => { ]) await setScrollTop(component, 2.5 * component.getLineHeight()) - expect(Array.from(element.querySelectorAll('.line-number')).slice(1).map(element => element.textContent.trim())).toEqual([ + expect(Array.from(element.querySelectorAll('.line-number:not(.dummy)')).map(element => element.textContent.trim())).toEqual([ '1', '2', '3', '4', '5', '6', '7', '8', '9' ]) - expect(Array.from(element.querySelectorAll('.line')).map(element => element.textContent)).toEqual([ + expect(Array.from(element.querySelectorAll('.line:not(.dummy)')).map(element => element.textContent)).toEqual([ editor.lineTextForScreenRow(0), editor.lineTextForScreenRow(1), editor.lineTextForScreenRow(2), @@ -2191,6 +2191,53 @@ describe('TextEditorComponent', () => { }) }) }) + + describe('styling changes', () => { + it('updates the rendered content based on new measurements when the font dimensions change', async () => { + const {component, element, editor} = buildComponent({rowsPerTile: 1, autoHeight: false}) + await setEditorHeightInLines(component, 3) + editor.setCursorScreenPosition([1, 29], {autoscroll: false}) + await component.getNextUpdatePromise() + + let cursorNode = element.querySelector('.cursor') + const initialBaseCharacterWidth = editor.getDefaultCharWidth() + const initialDoubleCharacterWidth = editor.getDoubleWidthCharWidth() + const initialHalfCharacterWidth = editor.getHalfWidthCharWidth() + const initialKoreanCharacterWidth = editor.getKoreanCharWidth() + const initialRenderedLineCount = element.querySelectorAll('.line:not(.dummy)').length + const initialFontSize = parseInt(getComputedStyle(element).fontSize) + + expect(initialKoreanCharacterWidth).toBeDefined() + expect(initialDoubleCharacterWidth).toBeDefined() + expect(initialHalfCharacterWidth).toBeDefined() + expect(initialBaseCharacterWidth).toBeDefined() + expect(initialDoubleCharacterWidth).not.toBe(initialBaseCharacterWidth) + expect(initialHalfCharacterWidth).not.toBe(initialBaseCharacterWidth) + expect(initialKoreanCharacterWidth).not.toBe(initialBaseCharacterWidth) + verifyCursorPosition(component, cursorNode, 1, 29) + + console.log(initialFontSize); + element.style.fontSize = initialFontSize - 5 + 'px' + TextEditor.didUpdateStyles() + await component.getNextUpdatePromise() + expect(editor.getDefaultCharWidth()).toBeLessThan(initialBaseCharacterWidth) + expect(editor.getDoubleWidthCharWidth()).toBeLessThan(initialDoubleCharacterWidth) + expect(editor.getHalfWidthCharWidth()).toBeLessThan(initialHalfCharacterWidth) + expect(editor.getKoreanCharWidth()).toBeLessThan(initialKoreanCharacterWidth) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBeGreaterThan(initialRenderedLineCount) + verifyCursorPosition(component, cursorNode, 1, 29) + + element.style.fontSize = initialFontSize + 5 + 'px' + TextEditor.didUpdateStyles() + await component.getNextUpdatePromise() + expect(editor.getDefaultCharWidth()).toBeGreaterThan(initialBaseCharacterWidth) + expect(editor.getDoubleWidthCharWidth()).toBeGreaterThan(initialDoubleCharacterWidth) + expect(editor.getHalfWidthCharWidth()).toBeGreaterThan(initialHalfCharacterWidth) + expect(editor.getKoreanCharWidth()).toBeGreaterThan(initialKoreanCharacterWidth) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBeLessThan(initialRenderedLineCount) + verifyCursorPosition(component, cursorNode, 1, 29) + }) + }) }) function buildEditor (params = {}) { @@ -2227,7 +2274,7 @@ function getBaseCharacterWidth (component) { } async function setEditorHeightInLines(component, heightInLines) { - component.element.style.height = component.measurements.lineHeight * heightInLines + 'px' + component.element.style.height = component.getLineHeight() * heightInLines + 'px' await component.getNextUpdatePromise() } diff --git a/src/atom-environment.coffee b/src/atom-environment.coffee index 1a6dd6cbe73..06d5331ff19 100644 --- a/src/atom-environment.coffee +++ b/src/atom-environment.coffee @@ -805,6 +805,7 @@ class AtomEnvironment extends Model @windowEventHandler = null didChangeStyles: (styleElement) -> + TextEditor.didUpdateStyles() if styleElement.textContent.indexOf('scrollbar') >= 0 TextEditor.didUpdateScrollbarStyles() diff --git a/src/text-editor-component.js b/src/text-editor-component.js index dbfb9f1982c..9d51bbd404a 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -29,6 +29,13 @@ const BLOCK_DECORATION_MEASUREMENT_AREA_VNODE = $.div({ visibility: 'hidden' } }) +const CHARACTER_MEASUREMENT_LINE_VNODE = $.div( + {key: 'characterMeasurementLine', ref: 'characterMeasurementLine', className: 'line dummy'}, + $.span({ref: 'normalWidthCharacterSpan'}, NORMAL_WIDTH_CHARACTER), + $.span({ref: 'doubleWidthCharacterSpan'}, DOUBLE_WIDTH_CHARACTER), + $.span({ref: 'halfWidthCharacterSpan'}, HALF_WIDTH_CHARACTER), + $.span({ref: 'koreanCharacterSpan'}, KOREAN_CHARACTER) +) function scaleMouseDragAutoscrollDelta (delta) { return Math.pow(delta / 3, 3) / 280 @@ -40,6 +47,14 @@ class TextEditorComponent { etch.setScheduler(scheduler) } + static didUpdateStyles () { + if (this.attachedComponents) { + this.attachedComponents.forEach((component) => { + component.didUpdateStyles() + }) + } + } + static didUpdateScrollbarStyles () { if (this.attachedComponents) { this.attachedComponents.forEach((component) => { @@ -79,7 +94,7 @@ class TextEditorComponent { this.lineNodesByScreenLineId = new Map() this.textNodesByScreenLineId = new Map() this.shouldRenderDummyScrollbars = true - this.refreshedScrollbarStyle = false + this.remeasureScrollbars = false this.pendingAutoscroll = null this.scrollTopPending = false this.scrollLeftPending = false @@ -161,6 +176,12 @@ class TextEditorComponent { return } + if (this.remeasureCharacterDimensions) { + this.measureCharacterDimensions() + this.measureGutterDimensions() + this.remeasureCharacterDimensions = false + } + this.measureBlockDecorations() this.measuredContent = false @@ -254,7 +275,7 @@ class TextEditorComponent { this.queryLineNumbersToRender() this.queryGuttersToRender() this.queryDecorationsToRender() - this.shouldRenderDummyScrollbars = !this.refreshedScrollbarStyle + this.shouldRenderDummyScrollbars = !this.remeasureScrollbars etch.updateSync(this) this.shouldRenderDummyScrollbars = true this.didMeasureVisibleBlockDecoration = false @@ -286,9 +307,9 @@ class TextEditorComponent { this.currentFrameLineNumberGutterProps = null this.scrollTopPending = false this.scrollLeftPending = false - if (this.refreshedScrollbarStyle) { + if (this.remeasureScrollbars) { this.measureScrollbarDimensions() - this.refreshedScrollbarStyle = false + this.remeasureScrollbars = false etch.updateSync(this) } } @@ -480,17 +501,13 @@ class TextEditorComponent { this.renderCursorsAndInput(), this.renderLineTiles(), BLOCK_DECORATION_MEASUREMENT_AREA_VNODE, + CHARACTER_MEASUREMENT_LINE_VNODE, this.renderPlaceholderText() ] } else { children = [ BLOCK_DECORATION_MEASUREMENT_AREA_VNODE, - $.div({ref: 'characterMeasurementLine', className: 'line'}, - $.span({ref: 'normalWidthCharacterSpan'}, NORMAL_WIDTH_CHARACTER), - $.span({ref: 'doubleWidthCharacterSpan'}, DOUBLE_WIDTH_CHARACTER), - $.span({ref: 'halfWidthCharacterSpan'}, HALF_WIDTH_CHARACTER), - $.span({ref: 'koreanCharacterSpan'}, KOREAN_CHARACTER) - ) + CHARACTER_MEASUREMENT_LINE_VNODE ] } @@ -505,8 +522,6 @@ class TextEditorComponent { } renderLineTiles () { - if (!this.measurements) return [] - const {lineNodesByScreenLineId, textNodesByScreenLineId} = this const startRow = this.getRenderedStartRow() @@ -676,7 +691,7 @@ class TextEditorComponent { this.isVerticalScrollbarVisible() ? this.getVerticalScrollbarWidth() : 0 - forceScrollbarVisible = this.refreshedScrollbarStyle + forceScrollbarVisible = this.remeasureScrollbars } else { forceScrollbarVisible = true } @@ -1117,7 +1132,7 @@ class TextEditorComponent { } didShow () { - if (!this.visible) { + if (!this.visible && this.isVisible()) { this.visible = true if (!this.measurements) this.performInitialMeasurements() this.props.model.setVisible(true) @@ -1235,8 +1250,14 @@ class TextEditorComponent { if (scrollTopChanged || scrollLeftChanged) this.updateSync() } + didUpdateStyles () { + this.remeasureCharacterDimensions = true + this.horizontalPixelPositionsByScreenLineId.clear() + this.scheduleUpdate() + } + didUpdateScrollbarStyles () { - this.refreshedScrollbarStyle = true + this.remeasureScrollbars = true this.scheduleUpdate() } @@ -1680,7 +1701,7 @@ class TextEditorComponent { this.measurements.baseCharacterWidth = this.refs.normalWidthCharacterSpan.getBoundingClientRect().width this.measurements.doubleWidthCharacterWidth = this.refs.doubleWidthCharacterSpan.getBoundingClientRect().width this.measurements.halfWidthCharacterWidth = this.refs.halfWidthCharacterSpan.getBoundingClientRect().width - this.measurements.koreanCharacterWidth = this.refs.koreanCharacterSpan.getBoundingClientRect().widt + this.measurements.koreanCharacterWidth = this.refs.koreanCharacterSpan.getBoundingClientRect().width this.props.model.setDefaultCharWidth( this.measurements.baseCharacterWidth, @@ -2444,7 +2465,7 @@ class LineNumberGutterComponent { mousedown: this.didMouseDown }, }, - $.div({key: 'placeholder', className: 'line-number', style: {visibility: 'hidden'}}, + $.div({key: 'placeholder', className: 'line-number dummy', style: {visibility: 'hidden'}}, '0'.repeat(maxDigits), $.div({className: 'icon-right'}) ), diff --git a/src/text-editor.coffee b/src/text-editor.coffee index bf3979a365b..7196e211857 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -65,6 +65,10 @@ class TextEditor extends Model TextEditorComponent ?= require './text-editor-component' TextEditorComponent.setScheduler(scheduler) + @didUpdateStyles: -> + TextEditorComponent ?= require './text-editor-component' + TextEditorComponent.didUpdateStyles() + @didUpdateScrollbarStyles: -> TextEditorComponent ?= require './text-editor-component' TextEditorComponent.didUpdateScrollbarStyles() From 2c6490c2e068831b360af489ee4c2d34720e02de Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 11 Apr 2017 15:16:53 -0600 Subject: [PATCH 206/403] Don't update editor component if we know we are not visible --- src/text-editor-component.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 9d51bbd404a..183a50ed62a 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -166,6 +166,8 @@ class TextEditorComponent { } updateSync (useScheduler = false) { + if (!this.visible) return + this.updateScheduled = false if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise() From 7aec696bb5a1e570badc08280fb72d14d299d7c8 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 11 Apr 2017 15:38:24 -0600 Subject: [PATCH 207/403] Remove stray logging --- spec/text-editor-component-spec.js | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index a23cb5e0094..8f0e154a372 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -2216,7 +2216,6 @@ describe('TextEditorComponent', () => { expect(initialKoreanCharacterWidth).not.toBe(initialBaseCharacterWidth) verifyCursorPosition(component, cursorNode, 1, 29) - console.log(initialFontSize); element.style.fontSize = initialFontSize - 5 + 'px' TextEditor.didUpdateStyles() await component.getNextUpdatePromise() From 6e6dce21eecacfe3f7f731b2f7f04fd38338e4bf Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 11 Apr 2017 15:42:22 -0600 Subject: [PATCH 208/403] Don't re-measure if editor has become invisible --- spec/text-editor-component-spec.js | 8 ++++++++ src/text-editor-component.js | 14 ++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 8f0e154a372..98006073481 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -2236,6 +2236,14 @@ describe('TextEditorComponent', () => { expect(element.querySelectorAll('.line:not(.dummy)').length).toBeLessThan(initialRenderedLineCount) verifyCursorPosition(component, cursorNode, 1, 29) }) + + it('gracefully handles the editor being hidden after a styling change', async () => { + const {component, element, editor} = buildComponent({autoHeight: false}) + element.style.fontSize = parseInt(getComputedStyle(element).fontSize) + 5 + 'px' + TextEditor.didUpdateStyles() + element.style.display = 'none' + await component.getNextUpdatePromise() + }) }) }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 183a50ed62a..925ee8722b8 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -166,15 +166,23 @@ class TextEditorComponent { } updateSync (useScheduler = false) { + this.updateScheduled = false + + // Don't proceed if we know we are not visible if (!this.visible) return - this.updateScheduled = false - if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise() + // Don't proceed if we have to pay for a measurement anyway and detect + // that we are no longer visible. + if ((this.remeasureCharacterDimensions || this.remeasureAllBlockDecorations) && !this.isVisible()) { + if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise() + return + } const onlyBlinkingCursors = this.nextUpdateOnlyBlinksCursors this.nextUpdateOnlyBlinksCursors = null if (onlyBlinkingCursors) { this.updateCursorBlinkSync() + if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise() return } @@ -202,6 +210,8 @@ class TextEditorComponent { this.measuredContent = true this.updateSyncAfterMeasuringContent() } + + if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise() } measureBlockDecorations () { From 3fce3ebe17446c84212bdb5061db84ddf3df785d Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 11 Apr 2017 15:44:28 -0600 Subject: [PATCH 209/403] Fix test --- spec/text-editor-component-spec.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 98006073481..0937da8102d 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -407,9 +407,8 @@ describe('TextEditorComponent', () => { it('supports the placeholderText parameter', () => { const placeholderText = 'Placeholder Test' - const {component} = buildComponent({placeholderText, text: ''}) - const emptyLineSpace = ' ' - expect(component.refs.content.textContent).toBe(emptyLineSpace + placeholderText) + const {element} = buildComponent({placeholderText, text: ''}) + expect(element.textContent).toContain(placeholderText) }) }) From 99e3c62e69d7cf2318e52b70374985b2339cdafa Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 11 Apr 2017 16:07:49 -0600 Subject: [PATCH 210/403] Clear highlight nodes when recycling line tiles --- spec/text-editor-component-spec.js | 13 +++++++++++++ src/text-editor-component.js | 5 ++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 0937da8102d..e7bd634b342 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -952,6 +952,19 @@ describe('TextEditorComponent', () => { expect(highlights[0].classList.contains('a')).toBe(true) expect(highlights[1].classList.contains('c')).toBe(true) }) + + it('clears highlights when recycling a tile that previously contained highlights and now does not', async () => { + const {component, element, editor} = buildComponent({rowsPerTile: 2, autoHeight: false}) + await setEditorHeightInLines(component, 2) + const marker = editor.markScreenRange([[1, 2], [1, 10]]) + editor.decorateMarker(marker, {type: 'highlight', class: 'a'}) + + await component.getNextUpdatePromise() + expect(element.querySelectorAll('.highlight.a').length).toBe(1) + + await setScrollTop(component, component.getLineHeight() * 3) + expect(element.querySelectorAll('.highlight.a').length).toBe(0) + }) }) describe('overlay decorations', () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 925ee8722b8..fd90dbaeb33 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2636,6 +2636,9 @@ class LinesTileComponent { if (newProps.width !== this.props.width) { this.linesVnode = null } + if (newProps.measuredContent || (!newProps.highlightDecorations && this.props.highlightDecorations)) { + this.highlightsVnode = null + } this.props = newProps etch.updateSync(this) } @@ -2665,7 +2668,7 @@ class LinesTileComponent { renderHighlights () { const {measuredContent, top, height, width, lineHeight, highlightDecorations} = this.props - if (measuredContent) { + if (!this.highlightsVnode) { let children = null if (highlightDecorations) { const decorationCount = highlightDecorations.length From 060a884ba9e50eacd95d800b3a08513f090212b0 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 11 Apr 2017 16:25:02 -0600 Subject: [PATCH 211/403] Include more properties in LinesTileComponent.shouldUpdate --- src/text-editor-component.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index fd90dbaeb33..54ac5437c92 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2723,6 +2723,7 @@ class LinesTileComponent { if (oldProps.top !== newProps.top) return true if (oldProps.height !== newProps.height) return true if (oldProps.width !== newProps.width) return true + if (oldProps.lineHeight !== newProps.lineHeight) return true if (!arraysEqual(oldProps.screenLines, newProps.screenLines)) return true if (!arraysEqual(oldProps.lineDecorations, newProps.lineDecorations)) return true @@ -2737,7 +2738,9 @@ class LinesTileComponent { const newHighlight = newProps.highlightDecorations[i] if (oldHighlight.className !== newHighlight.className) return true if (newHighlight.flashRequested) return true + if (oldHighlight.startPixelTop !== newHighlight.startPixelTop) return true if (oldHighlight.startPixelLeft !== newHighlight.startPixelLeft) return true + if (oldHighlight.endPixelTop !== newHighlight.endPixelTop) return true if (oldHighlight.endPixelLeft !== newHighlight.endPixelLeft) return true if (!oldHighlight.screenRange.isEqual(newHighlight.screenRange)) return true } From b99ddfd3bfb7a83cdefc04e2a34d91ee640ca524 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 11 Apr 2017 16:47:33 -0600 Subject: [PATCH 212/403] Remove unused var --- src/text-editor-component.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 54ac5437c92..42b10ba3910 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2662,11 +2662,10 @@ class LinesTileComponent { this.renderHighlights(), this.renderLines() ) - } renderHighlights () { - const {measuredContent, top, height, width, lineHeight, highlightDecorations} = this.props + const {top, height, width, lineHeight, highlightDecorations} = this.props if (!this.highlightsVnode) { let children = null From 8aabd026adad3c03d593600700c70c169e7a1492 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 11 Apr 2017 16:58:35 -0600 Subject: [PATCH 213/403] Remove highlight caching for now --- src/text-editor-component.js | 50 +++++++++++++++--------------------- 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 42b10ba3910..55eb28584c1 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2627,7 +2627,6 @@ class LinesTileComponent { constructor (props) { this.props = props this.linesVnode = null - this.highlightsVnode = null etch.initialize(this) } @@ -2636,9 +2635,6 @@ class LinesTileComponent { if (newProps.width !== this.props.width) { this.linesVnode = null } - if (newProps.measuredContent || (!newProps.highlightDecorations && this.props.highlightDecorations)) { - this.highlightsVnode = null - } this.props = newProps etch.updateSync(this) } @@ -2667,34 +2663,30 @@ class LinesTileComponent { renderHighlights () { const {top, height, width, lineHeight, highlightDecorations} = this.props - if (!this.highlightsVnode) { - let children = null - if (highlightDecorations) { - const decorationCount = highlightDecorations.length - children = new Array(decorationCount) - for (let i = 0; i < decorationCount; i++) { - const highlightProps = Object.assign( - {parentTileTop: top, lineHeight}, - highlightDecorations[i] - ) - children[i] = $(HighlightComponent, highlightProps) - highlightDecorations[i].flashRequested = false - } + let children = null + if (highlightDecorations) { + const decorationCount = highlightDecorations.length + children = new Array(decorationCount) + for (let i = 0; i < decorationCount; i++) { + const highlightProps = Object.assign( + {parentTileTop: top, lineHeight}, + highlightDecorations[i] + ) + children[i] = $(HighlightComponent, highlightProps) + highlightDecorations[i].flashRequested = false } - - this.highlightsVnode = $.div( - { - style: { - position: 'absolute', - contain: 'strict', - height: height + 'px', - width: width + 'px' - }, - }, children - ) } - return this.highlightsVnode + return $.div( + { + style: { + position: 'absolute', + contain: 'strict', + height: height + 'px', + width: width + 'px' + }, + }, children + ) } renderLines () { From c83cd34e0249e61a9f8e8dfe7c4e1230f1c06d99 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 11 Apr 2017 17:45:57 -0600 Subject: [PATCH 214/403] Slice lines and decorations passed to LinesTileComponent This ensures the component's shouldUpdate method works correctly. --- src/text-editor-component.js | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 55eb28584c1..b4245a8cd9c 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -558,8 +558,8 @@ class TextEditorComponent { lineHeight: this.getLineHeight(), renderedStartRow: startRow, tileStartRow, tileEndRow, - screenLines: this.renderedScreenLines, - lineDecorations: this.decorationsToRender.lines, + screenLines: this.renderedScreenLines.slice(tileStartRow - startRow, tileEndRow - startRow), + lineDecorations: this.decorationsToRender.lines.slice(tileStartRow - startRow, tileEndRow - startRow), blockDecorations: this.decorationsToRender.blocks.get(tileStartRow), highlightDecorations: this.decorationsToRender.highlights.get(tileStartRow), displayLayer, @@ -2692,7 +2692,6 @@ class LinesTileComponent { renderLines () { const { measuredContent, height, width, top, - renderedStartRow, tileStartRow, tileEndRow, screenLines, lineDecorations, blockDecorations, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId } = this.props @@ -2700,7 +2699,6 @@ class LinesTileComponent { if (!measuredContent || !this.linesVnode) { this.linesVnode = $(LinesComponent, { height, width, - renderedStartRow, tileStartRow, tileEndRow, screenLines, lineDecorations, blockDecorations, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId }) @@ -2771,7 +2769,7 @@ class LinesComponent { constructor (props) { this.props = {} const { - width, height, tileStartRow, tileEndRow, renderedStartRow, + width, height, screenLines, lineDecorations, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId } = props @@ -2783,13 +2781,9 @@ class LinesComponent { this.element.style.width = width + 'px' this.lineComponents = [] - for (let row = tileStartRow; row < tileEndRow; row++) { - const i = row - renderedStartRow - const screenLine = screenLines[i] - if (!screenLine) break - + for (let i = 0, length = screenLines.length; i < length; i++) { const component = new LineComponent({ - screenLine, + screenLine: screenLines[i], lineDecoration: lineDecorations[i], displayLayer, lineNodesByScreenLineId, @@ -2827,17 +2821,16 @@ class LinesComponent { updateLines (props) { var { - tileStartRow, tileEndRow, renderedStartRow, screenLines, lineDecorations, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId } = props var oldScreenLines = this.props.screenLines var newScreenLines = screenLines - var oldScreenLinesEndIndex = this.props.tileEndRow - this.props.renderedStartRow - var newScreenLinesEndIndex = tileEndRow - renderedStartRow - var oldScreenLineIndex = this.props.tileStartRow - this.props.renderedStartRow - var newScreenLineIndex = tileStartRow - renderedStartRow + var oldScreenLinesEndIndex = oldScreenLines.length + var newScreenLinesEndIndex = newScreenLines.length + var oldScreenLineIndex = 0 + var newScreenLineIndex = 0 var lineComponentIndex = 0 while (oldScreenLineIndex < oldScreenLinesEndIndex || newScreenLineIndex < newScreenLinesEndIndex) { From 8707cabe403415c20a22549cf1f0bb0118e35750 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 12 Apr 2017 10:02:57 +0200 Subject: [PATCH 215/403] Don't count the dummy line in block decoration test --- spec/text-editor-component-spec.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index e7bd634b342..818eb78f4ee 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1221,7 +1221,7 @@ describe('TextEditorComponent', () => { {tileStartRow: 3, height: 3 * component.getLineHeight()} ]) assertLinesAreAlignedWithLineNumbers(component) - expect(element.querySelectorAll('.line').length).toBe(6) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) expect(item1.previousSibling).toBeNull() expect(item1.nextSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 1)) @@ -1245,7 +1245,7 @@ describe('TextEditorComponent', () => { {tileStartRow: 3, height: 3 * component.getLineHeight() + getElementHeight(item3)} ]) assertLinesAreAlignedWithLineNumbers(component) - expect(element.querySelectorAll('.line').length).toBe(6) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) expect(item1.previousSibling).toBeNull() expect(item1.nextSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 1)) @@ -1270,7 +1270,7 @@ describe('TextEditorComponent', () => { {tileStartRow: 6, height: 3 * component.getLineHeight() + getElementHeight(item4) + getElementHeight(item5)} ]) assertLinesAreAlignedWithLineNumbers(component) - expect(element.querySelectorAll('.line').length).toBe(6) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) expect(element.contains(item1)).toBe(false) expect(element.contains(item2)).toBe(false) expect(item3.previousSibling).toBe(lineNodeForScreenRow(component, 3)) @@ -1297,7 +1297,7 @@ describe('TextEditorComponent', () => { {tileStartRow: 3, height: 3 * component.getLineHeight() + getElementHeight(item3)} ]) assertLinesAreAlignedWithLineNumbers(component) - expect(element.querySelectorAll('.line').length).toBe(6) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 1)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 2)) @@ -1323,7 +1323,7 @@ describe('TextEditorComponent', () => { {tileStartRow: 3, height: 3 * component.getLineHeight()} ]) assertLinesAreAlignedWithLineNumbers(component) - expect(element.querySelectorAll('.line').length).toBe(6) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) @@ -1348,7 +1348,7 @@ describe('TextEditorComponent', () => { {tileStartRow: 3, height: 3 * component.getLineHeight() + getElementHeight(item2)} ]) assertLinesAreAlignedWithLineNumbers(component) - expect(element.querySelectorAll('.line').length).toBe(6) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBeNull() expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 3)) @@ -1373,7 +1373,7 @@ describe('TextEditorComponent', () => { {tileStartRow: 3, height: 3 * component.getLineHeight()} ]) assertLinesAreAlignedWithLineNumbers(component) - expect(element.querySelectorAll('.line').length).toBe(6) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) @@ -1405,7 +1405,7 @@ describe('TextEditorComponent', () => { {tileStartRow: 3, height: 3 * component.getLineHeight()} ]) assertLinesAreAlignedWithLineNumbers(component) - expect(element.querySelectorAll('.line').length).toBe(6) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) @@ -1444,7 +1444,7 @@ describe('TextEditorComponent', () => { {tileStartRow: 3, height: 3 * component.getLineHeight()} ]) assertLinesAreAlignedWithLineNumbers(component) - expect(element.querySelectorAll('.line').length).toBe(6) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) @@ -1471,7 +1471,7 @@ describe('TextEditorComponent', () => { {tileStartRow: 6, height: 3 * component.getLineHeight() + getElementHeight(item4) + getElementHeight(item5)}, ]) assertLinesAreAlignedWithLineNumbers(component) - expect(element.querySelectorAll('.line').length).toBe(9) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(9) expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) From c8aeee97866757d6012fcfee01b067685f2b093f Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 12 Apr 2017 10:04:59 +0200 Subject: [PATCH 216/403] Fix bad syntax in src/initialize-benchmark-window.js --- src/initialize-benchmark-window.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/initialize-benchmark-window.js b/src/initialize-benchmark-window.js index 166319b94d1..7ba99c468f6 100644 --- a/src/initialize-benchmark-window.js +++ b/src/initialize-benchmark-window.js @@ -54,7 +54,7 @@ export default async function () { const clipboard = new Clipboard() TextEditor.setClipboard(clipboard) - TextEditor.viewForItem = (item) -> atom.views.getView(item) + TextEditor.viewForItem = (item) => atom.views.getView(item) const applicationDelegate = new ApplicationDelegate() const environmentParams = { From 6742025a02d6ceb599d74ce85278784db949e713 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 12 Apr 2017 10:21:04 +0200 Subject: [PATCH 217/403] Import octicon-mixins in static/text-editor.less --- static/text-editor.less | 2 ++ 1 file changed, 2 insertions(+) diff --git a/static/text-editor.less b/static/text-editor.less index 850907b671c..90164d04022 100644 --- a/static/text-editor.less +++ b/static/text-editor.less @@ -1,3 +1,5 @@ +@import "octicon-mixins.less"; + atom-text-editor { position: relative; From a99237b33b537dda8be253992004e7546c3a19fc Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 12 Apr 2017 10:35:42 +0200 Subject: [PATCH 218/403] Fix lint errors and delete dead code --- package.json | 6 +- src/decoration-manager.js | 2 +- src/gutter.coffee | 13 ---- src/text-editor-component.js | 123 ++++++++++++++----------------- src/text-editor-element.js | 11 ++- src/tokenized-buffer-iterator.js | 1 - 6 files changed, 67 insertions(+), 89 deletions(-) diff --git a/package.json b/package.json index 76cd869c072..60b447ce8b5 100644 --- a/package.json +++ b/package.json @@ -187,7 +187,11 @@ "spyOn", "waitsFor", "waitsForPromise", - "indexedDB" + "indexedDB", + "IntersectionObserver", + "FocusEvent", + "requestAnimationFrame", + "HTMLElement" ] } } diff --git a/src/decoration-manager.js b/src/decoration-manager.js index a37eafe7fca..7a9269cae29 100644 --- a/src/decoration-manager.js +++ b/src/decoration-manager.js @@ -160,7 +160,7 @@ class DecorationManager { const layerDecorations = this.layerDecorationsByMarkerLayer.get(layer) if (layerDecorations) { layerDecorations.forEach((layerDecoration) => { - const properties = layerDecoration.getPropertiesForMarker(marker) || layerDecoration.getProperties() + const properties = layerDecoration.getPropertiesForMarker(marker) || layerDecoration.getProperties() decorationsState[`${layerDecoration.id}-${marker.id}`] = { properties, screenRange, diff --git a/src/gutter.coffee b/src/gutter.coffee index 19792ff12c3..6b39398dd97 100644 --- a/src/gutter.coffee +++ b/src/gutter.coffee @@ -29,19 +29,6 @@ class Gutter @emitter.emit 'did-destroy' @emitter.dispose() - getElement: -> - unless @element? - @element = document.createElement('div') - @element.classList.add('gutter') - @element.setAttribute('gutter-name', @name) - childNode = document.createElement('div') - if @name is 'line-number' - childNode.classList.add('line-numbers') - else - childNode.classList.add('custom-decorations') - @element.appendChild(childNode) - @element - ### Section: Event Subscription ### diff --git a/src/text-editor-component.js b/src/text-editor-component.js index b4245a8cd9c..994dfdb7f13 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -460,7 +460,7 @@ class TextEditorComponent { didMeasureVisibleBlockDecoration: this.didMeasureVisibleBlockDecoration, height: this.getScrollHeight(), width: this.getLineNumberGutterWidth(), - lineHeight: this.getLineHeight(), + lineHeight: this.getLineHeight() }) } else { return $(LineNumberGutterComponent, { @@ -557,7 +557,8 @@ class TextEditorComponent { top: this.pixelPositionBeforeBlocksForRow(tileStartRow), lineHeight: this.getLineHeight(), renderedStartRow: startRow, - tileStartRow, tileEndRow, + tileStartRow, + tileEndRow, screenLines: this.renderedScreenLines.slice(tileStartRow - startRow, tileEndRow - startRow), lineDecorations: this.decorationsToRender.lines.slice(tileStartRow - startRow, tileEndRow - startRow), blockDecorations: this.decorationsToRender.blocks.get(tileStartRow), @@ -687,8 +688,8 @@ class TextEditorComponent { renderDummyScrollbars () { if (this.shouldRenderDummyScrollbars) { - let scrollHeight, scrollTop, horizontalScrollbarHeight, - scrollWidth, scrollLeft, verticalScrollbarWidth, forceScrollbarVisible + let scrollHeight, scrollTop, horizontalScrollbarHeight + let scrollWidth, scrollLeft, verticalScrollbarWidth, forceScrollbarVisible if (this.measurements) { scrollHeight = this.getScrollHeight() @@ -714,14 +715,20 @@ class TextEditorComponent { orientation: 'vertical', didScroll: this.didScrollDummyScrollbar, didMousedown: this.didMouseDownOnContent, - scrollHeight, scrollTop, horizontalScrollbarHeight, forceScrollbarVisible + scrollHeight, + scrollTop, + horizontalScrollbarHeight, + forceScrollbarVisible }), $(DummyScrollbarComponent, { ref: 'horizontalScrollbar', orientation: 'horizontal', didScroll: this.didScrollDummyScrollbar, didMousedown: this.didMouseDownOnContent, - scrollWidth, scrollLeft, verticalScrollbarWidth, forceScrollbarVisible + scrollWidth, + scrollLeft, + verticalScrollbarWidth, + forceScrollbarVisible }) ] @@ -862,7 +869,7 @@ class TextEditorComponent { decorationsByMarker.forEach((decorations, marker) => { const screenRange = marker.getScreenRange() const reversed = marker.isReversed() - for (let i = 0, length = decorations.length; i < decorations.length; i++) { + for (let i = 0; i < decorations.length; i++) { const decoration = decorations[i] this.addDecorationToRender(decoration.type, decoration, marker, screenRange, reversed) } @@ -935,7 +942,7 @@ class TextEditorComponent { } } - addHighlightDecorationToMeasure(decoration, screenRange, key) { + addHighlightDecorationToMeasure (decoration, screenRange, key) { screenRange = constrainRangeToRows(screenRange, this.getRenderedStartRow(), this.getRenderedEndRow()) if (screenRange.isEmpty()) return @@ -957,7 +964,11 @@ class TextEditorComponent { tileHighlights.push({ screenRange: screenRangeInTile, - key, className, flashRequested, flashClass, flashDuration + key, + className, + flashRequested, + flashClass, + flashDuration }) this.requestHorizontalMeasurement(screenRangeInTile.start.row, screenRangeInTile.start.column) @@ -1008,7 +1019,8 @@ class TextEditorComponent { decorations.push({ className: decoration.class, element: TextEditor.viewForItem(decoration.item), - top, height + top, + height }) } @@ -1055,7 +1067,6 @@ class TextEditorComponent { updateCursorsToRender () { this.decorationsToRender.cursors.length = 0 - const height = this.getLineHeight() + 'px' for (let i = 0; i < this.decorationsToMeasure.cursors.length; i++) { const cursor = this.decorationsToMeasure.cursors[i] const {row, column} = cursor.screenPosition @@ -1220,7 +1231,7 @@ class TextEditorComponent { } } - didMouseWheel (eveWt) { + didMouseWheel (event) { let {deltaX, deltaY} = event deltaX = deltaX * MOUSE_WHEEL_SCROLL_SENSITIVITY deltaY = deltaY * MOUSE_WHEEL_SCROLL_SENSITIVITY @@ -1827,18 +1838,18 @@ class TextEditorComponent { let textNodeStartColumn = 0 let textNodesIndex = 0 - columnLoop: + columnLoop: // eslint-disable-line no-labels for (let columnsIndex = 0; columnsIndex < columnsToMeasure.length; columnsIndex++) { while (textNodesIndex < textNodes.length) { const nextColumnToMeasure = columnsToMeasure[columnsIndex] if (nextColumnToMeasure === 0) { positions.set(0, 0) - continue columnLoop + continue columnLoop // eslint-disable-line no-labels } if (nextColumnToMeasure >= lineNode.textContent.length) { } - if (positions.has(nextColumnToMeasure)) continue columnLoop + if (positions.has(nextColumnToMeasure)) continue columnLoop // eslint-disable-line no-labels const textNode = textNodes[textNodesIndex] const textNodeEndColumn = textNodeStartColumn + textNode.textContent.length @@ -1851,7 +1862,7 @@ class TextEditorComponent { } if (lineNodeClientLeft === -1) lineNodeClientLeft = lineNode.getBoundingClientRect().left positions.set(nextColumnToMeasure, clientPixelPosition - lineNodeClientLeft) - continue columnLoop + continue columnLoop // eslint-disable-line no-labels } else { textNodesIndex++ textNodeStartColumn = textNodeEndColumn @@ -1878,7 +1889,7 @@ class TextEditorComponent { return this.horizontalPixelPositionsByScreenLineId.get(screenLine.id).get(column) } - screenPositionForPixelPosition({top, left}) { + screenPositionForPixelPosition ({top, left}) { const {model} = this.props const row = Math.min( @@ -1961,9 +1972,6 @@ class TextEditorComponent { this.disposables.add(model.displayLayer.onDidChangeSync((changes) => { for (let i = 0; i < changes.length; i++) { const change = changes[i] - const startRow = change.start.row - const endRow = startRow + change.oldExtent.row - const rowDelta = change.newExtent.row - change.oldExtent.row this.spliceLineTopIndex( change.start.row, change.oldExtent.row, @@ -1986,7 +1994,7 @@ class TextEditorComponent { observeBlockDecoration (decoration) { const marker = decoration.getMarker() - const {item, position} = decoration.getProperties() + const {position} = decoration.getProperties() const row = marker.getHeadScreenPosition().row this.lineTopIndex.insertBlock(decoration, row, 0, position === 'after') @@ -2411,7 +2419,6 @@ class LineNumberGutterComponent { const renderedTileCount = parentComponent.getRenderedTileCount() children = new Array(renderedTileCount) - let softWrapCount = 0 for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow = tileStartRow + rowsPerTile) { const tileEndRow = Math.min(endRow, tileStartRow + rowsPerTile) const tileChildren = new Array(tileEndRow - tileStartRow) @@ -2475,7 +2482,7 @@ class LineNumberGutterComponent { style: {position: 'relative', height: height + 'px'}, on: { mousedown: this.didMouseDown - }, + } }, $.div({key: 'placeholder', className: 'line-number dummy', style: {visibility: 'hidden'}}, '0'.repeat(maxDigits), @@ -2585,7 +2592,7 @@ class CustomGutterComponent { renderDecorations () { if (!this.props.decorations) return null - return this.props.decorations.map(({className, element, top, height}) => { + return this.props.decorations.map(({className, element, top, height}) => { return $(CustomGutterDecorationComponent, { className, element, @@ -2613,10 +2620,10 @@ class CustomGutterDecorationComponent { const oldProps = this.props this.props = newProps - if (newProps.top != oldProps.top) this.element.style.top = newProps.top + 'px' - if (newProps.height != oldProps.height) this.element.style.height = newProps.height + 'px' - if (newProps.className != oldProps.className) this.element.className = newProps.className || '' - if (newProps.element != oldProps.element) { + if (newProps.top !== oldProps.top) this.element.style.top = newProps.top + 'px' + if (newProps.height !== oldProps.height) this.element.style.height = newProps.height + 'px' + if (newProps.className !== oldProps.className) this.element.className = newProps.className || '' + if (newProps.element !== oldProps.element) { if (this.element.firstChild) this.element.firstChild.remove() this.element.appendChild(newProps.element) } @@ -2684,23 +2691,28 @@ class LinesTileComponent { contain: 'strict', height: height + 'px', width: width + 'px' - }, + } }, children ) } renderLines () { const { - measuredContent, height, width, top, + measuredContent, height, width, screenLines, lineDecorations, blockDecorations, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId } = this.props if (!measuredContent || !this.linesVnode) { this.linesVnode = $(LinesComponent, { - height, width, - screenLines, lineDecorations, blockDecorations, displayLayer, - lineNodesByScreenLineId, textNodesByScreenLineId + height, + width, + screenLines, + lineDecorations, + blockDecorations, + displayLayer, + lineNodesByScreenLineId, + textNodesByScreenLineId }) } @@ -2868,8 +2880,7 @@ class LinesComponent { if (newScreenLineIndex < oldScreenLineIndexInNewScreenLines && oldScreenLineIndexInNewScreenLines < newScreenLinesEndIndex) { var newScreenLineComponents = [] while (newScreenLineIndex < oldScreenLineIndexInNewScreenLines) { - var oldScreenLineComponent = this.lineComponents[lineComponentIndex] - var newScreenLineComponent = new LineComponent({ + var newScreenLineComponent = new LineComponent({ // eslint-disable-line no-redeclare screenLine: newScreenLines[newScreenLineIndex], lineDecoration: lineDecorations[newScreenLineIndex], displayLayer, @@ -2893,7 +2904,7 @@ class LinesComponent { } } else { var oldScreenLineComponent = this.lineComponents[lineComponentIndex] - var newScreenLineComponent = new LineComponent({ + var newScreenLineComponent = new LineComponent({ // eslint-disable-line no-redeclare screenLine: newScreenLines[newScreenLineIndex], lineDecoration: lineDecorations[newScreenLineIndex], displayLayer, @@ -2916,7 +2927,7 @@ class LinesComponent { var blockDecorations = this.props.blockDecorations ? this.props.blockDecorations.get(screenLine.id) : null if (blockDecorations) { var blockDecorationElementsBeforeOldScreenLine = [] - for (var i = 0; i < blockDecorations.length; i++) { + for (let i = 0; i < blockDecorations.length; i++) { var decoration = blockDecorations[i] if (decoration.position !== 'after') { blockDecorationElementsBeforeOldScreenLine.push( @@ -2925,7 +2936,7 @@ class LinesComponent { } } - for (var i = 0; i < blockDecorationElementsBeforeOldScreenLine.length; i++) { + for (let i = 0; i < blockDecorationElementsBeforeOldScreenLine.length; i++) { var blockDecorationElement = blockDecorationElementsBeforeOldScreenLine[i] if (!blockDecorationElementsBeforeOldScreenLine.includes(blockDecorationElement.previousSibling)) { return blockDecorationElement @@ -2954,8 +2965,8 @@ class LinesComponent { }) } - if (props.blockDecorations) { - props.blockDecorations.forEach((newDecorations, screenLineId) => { + if (blockDecorations) { + blockDecorations.forEach((newDecorations, screenLineId) => { var oldDecorations = this.props.blockDecorations ? this.props.blockDecorations.get(screenLineId) : null for (var i = 0; i < newDecorations.length; i++) { var newDecoration = newDecorations[i] @@ -2976,7 +2987,7 @@ class LinesComponent { class LineComponent { constructor (props) { - const {displayLayer, screenLine, lineDecoration, lineNodesByScreenLineId, textNodesByScreenLineId} = props + const {displayLayer, screenLine, lineNodesByScreenLineId, textNodesByScreenLineId} = props this.props = props this.element = document.createElement('div') this.element.className = this.buildClassName() @@ -3087,7 +3098,7 @@ class HighlightComponent { let {startPixelTop, endPixelTop} = this.props const { className, screenRange, parentTileTop, lineHeight, - startPixelLeft, endPixelLeft, + startPixelLeft, endPixelLeft } = this.props startPixelTop -= parentTileTop endPixelTop -= parentTileTop @@ -3177,32 +3188,6 @@ class OverlayComponent { } } -class ComponentWrapper { - constructor (props) { - this.component = props.component - this.element = this.component.element - this.component.update(props) - } - - update (props) { - this.component.update(props) - } - - destroy () { - this.component.destroy() - } -} - -class ElementComponent { - constructor ({element}) { - this.element = element - } - - update ({element}) { - this.element = element - } -} - const classNamesByScopeName = new Map() function classNameForScopeName (scopeName) { let classString = classNamesByScopeName.get(scopeName) @@ -3227,7 +3212,7 @@ function getElementResizeDetector () { return resizeDetector } -function arraysEqual(a, b) { +function arraysEqual (a, b) { if (a.length !== b.length) return false for (let i = 0, length = a.length; i < length; i++) { if (a[i] !== b[i]) return false diff --git a/src/text-editor-element.js b/src/text-editor-element.js index 23a5aeb6ef8..f53c696353a 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -54,10 +54,13 @@ class TextEditorElement extends HTMLElement { } getComponent () { - if (!this.component) this.component = new TextEditorComponent({ - element: this, - updatedSynchronously: this.updatedSynchronously - }) + if (!this.component) { + this.component = new TextEditorComponent({ + element: this, + updatedSynchronously: this.updatedSynchronously + }) + } + return this.component } diff --git a/src/tokenized-buffer-iterator.js b/src/tokenized-buffer-iterator.js index 614cb01a997..540b4ad3a2c 100644 --- a/src/tokenized-buffer-iterator.js +++ b/src/tokenized-buffer-iterator.js @@ -176,7 +176,6 @@ module.exports = class TokenizedBufferIterator { prefixedScopes.set(scope, prefixedScope) return prefixedScope } - return } else { return null } From 2993f3c1ac00d0d0431a4a11b6da1d0b77290620 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 12 Apr 2017 10:48:21 +0200 Subject: [PATCH 219/403] Further optimize line replacement --- src/text-editor-component.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 994dfdb7f13..0b5a78d8a5b 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2912,7 +2912,12 @@ class LinesComponent { textNodesByScreenLineId }) this.element.insertBefore(newScreenLineComponent.element, oldScreenLineComponent.element) - oldScreenLineComponent.destroy() + // Instead of calling destroy on the component here we can simply + // remove its associated element, thus skipping the + // lineNodesByScreenLineId bookkeeping. This is possible because + // lineNodesByScreenLineId has already been updated when creating the + // new screen line component. + oldScreenLineComponent.element.remove() this.lineComponents[lineComponentIndex] = newScreenLineComponent oldScreenLineIndex++ From 1d8f4f2cdd26633d9ae2c431e09fcf61cbdcf888 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 12 Apr 2017 15:09:37 +0200 Subject: [PATCH 220/403] Wait until the editor is focused before starting to type in smoke test --- spec/integration/smoke-spec.coffee | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/integration/smoke-spec.coffee b/spec/integration/smoke-spec.coffee index 527ed1f8fb3..dd689b47643 100644 --- a/spec/integration/smoke-spec.coffee +++ b/spec/integration/smoke-spec.coffee @@ -6,7 +6,7 @@ runAtom = require './helpers/start-atom' describe "Smoke Test", -> return unless process.platform is 'darwin' # Fails on win32 - + atomHome = temp.mkdirSync('atom-home') beforeEach -> @@ -28,6 +28,7 @@ describe "Smoke Test", -> .then (exists) -> expect(exists).toBe true .waitForPaneItemCount(1, 1000) .click("atom-text-editor") + .waitUntil((-> @execute(-> document.activeElement.closest('atom-text-editor'))), 5000) .keys("Hello!") .execute -> atom.workspace.getActiveTextEditor().getText() .then ({value}) -> expect(value).toBe "Hello!" From 0210b0bc81ff60c09a83198f3d0bfc1c3481eaf9 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 12 Apr 2017 15:12:06 +0200 Subject: [PATCH 221/403] Update fake gutter container interface in gutter-spec.coffee --- spec/gutter-spec.coffee | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/gutter-spec.coffee b/spec/gutter-spec.coffee index 30748b78732..47c5983f686 100644 --- a/spec/gutter-spec.coffee +++ b/spec/gutter-spec.coffee @@ -1,7 +1,9 @@ Gutter = require '../src/gutter' describe 'Gutter', -> - fakeGutterContainer = {} + fakeGutterContainer = { + scheduleComponentUpdate: -> + } name = 'name' describe '::hide', -> From b7a421eadf225f46c9ee0ac2966b77da1831df1c Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 12 Apr 2017 15:12:58 +0200 Subject: [PATCH 222/403] Stop calling `initialize` in `ViewRegistry` tests --- spec/view-registry-spec.coffee | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/view-registry-spec.coffee b/spec/view-registry-spec.coffee index c650184e298..4bae1d81160 100644 --- a/spec/view-registry-spec.coffee +++ b/spec/view-registry-spec.coffee @@ -5,7 +5,6 @@ describe "ViewRegistry", -> beforeEach -> registry = new ViewRegistry - registry.initialize() afterEach -> registry.clearDocumentRequests() From e2cf60a0c92f6db61cea26b817b7b7a5d51271eb Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 12 Apr 2017 19:07:05 +0200 Subject: [PATCH 223/403] Don't reuse resize detectors across `TextEditorComponent` instances Due to the way element-resize-detector schedules the delivering of resize events, this will ensure that creating an editor while the clock is mocked won't prevent subsequent tests using the real clock from getting such events. --- src/text-editor-component.js | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 0b5a78d8a5b..cd8ebcb7705 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -138,9 +138,10 @@ class TextEditorComponent { etch.updateSync(this) this.observeModel() - getElementResizeDetector().listenTo(this.element, this.didResize.bind(this)) + this.resizeDetector = ResizeDetector({strategy: 'scroll'}) + this.resizeDetector.listenTo(this.element, this.didResize.bind(this)) if (this.refs.gutterContainer) { - getElementResizeDetector().listenTo(this.refs.gutterContainer, this.didResizeGutterContainer.bind(this)) + this.resizeDetector.listenTo(this.refs.gutterContainer, this.didResizeGutterContainer.bind(this)) } } @@ -759,7 +760,7 @@ class TextEditorComponent { renderOverlayDecorations () { return this.decorationsToRender.overlays.map((overlayProps) => $(OverlayComponent, Object.assign( - {key: overlayProps.element, didResize: this.updateSync}, + {key: overlayProps.element, resizeDetector: this.resizeDetector, didResize: this.updateSync}, overlayProps )) ) @@ -3178,7 +3179,7 @@ class OverlayComponent { this.element.style.zIndex = 4 this.element.style.top = (this.props.pixelTop || 0) + 'px' this.element.style.left = (this.props.pixelLeft || 0) + 'px' - getElementResizeDetector().listenTo(this.element, this.props.didResize) + this.props.resizeDetector.listenTo(this.element, this.props.didResize) } update (newProps) { @@ -3211,12 +3212,6 @@ function clientRectForRange (textNode, startIndex, endIndex) { return rangeForMeasurement.getBoundingClientRect() } -let resizeDetector -function getElementResizeDetector () { - if (resizeDetector == null) resizeDetector = ResizeDetector({strategy: 'scroll'}) - return resizeDetector -} - function arraysEqual (a, b) { if (a.length !== b.length) return false for (let i = 0, length = a.length; i < length; i++) { From 1d01d499a9e5091424febe98c93adf2da541631b Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 13 Apr 2017 14:57:35 +0200 Subject: [PATCH 224/403] Fix spec/text-editor-spec.coffee --- spec/text-editor-spec.coffee | 55 +----------------------------------- src/decoration-manager.js | 2 +- 2 files changed, 2 insertions(+), 55 deletions(-) diff --git a/spec/text-editor-spec.coffee b/spec/text-editor-spec.coffee index 9b7d1d673c2..1d50e0b79cc 100644 --- a/spec/text-editor-spec.coffee +++ b/spec/text-editor-spec.coffee @@ -137,7 +137,7 @@ describe "TextEditor", -> autoHeight: false }) - expect(returnedPromise).toBe(atom.views.getNextUpdatePromise()) + expect(returnedPromise).toBe(element.component.getNextUpdatePromise()) expect(changeSpy.callCount).toBe(1) expect(editor.getTabLength()).toBe(6) expect(editor.getSoftTabs()).toBe(false) @@ -1877,8 +1877,6 @@ describe "TextEditor", -> [[4, 16], [4, 21]] [[4, 25], [4, 29]] ] - for cursor in editor.getCursors() - expect(cursor.isVisible()).toBeTruthy() it "skips lines that are too short to create a non-empty selection", -> editor.setSelectedBufferRange([[3, 31], [3, 38]]) @@ -2010,8 +2008,6 @@ describe "TextEditor", -> [[2, 16], [2, 21]] [[2, 37], [2, 40]] ] - for cursor in editor.getCursors() - expect(cursor.isVisible()).toBeTruthy() it "skips lines that are too short to create a non-empty selection", -> editor.setSelectedBufferRange([[6, 31], [6, 38]]) @@ -2181,54 +2177,6 @@ describe "TextEditor", -> editor.setCursorScreenPosition([3, 3]) expect(selection.isEmpty()).toBeTruthy() - describe "cursor visibility while there is a selection", -> - describe "when showCursorOnSelection is true", -> - it "is visible while there is no selection", -> - expect(selection.isEmpty()).toBeTruthy() - expect(editor.getShowCursorOnSelection()).toBeTruthy() - expect(editor.getCursors().length).toBe 1 - expect(editor.getCursors()[0].isVisible()).toBeTruthy() - - it "is visible while there is a selection", -> - expect(selection.isEmpty()).toBeTruthy() - editor.setSelectedBufferRange([[1, 2], [1, 5]]) - expect(selection.isEmpty()).toBeFalsy() - expect(editor.getCursors().length).toBe 1 - expect(editor.getCursors()[0].isVisible()).toBeTruthy() - - it "is visible while there are multiple selections", -> - expect(editor.getSelections().length).toBe 1 - editor.setSelectedBufferRanges([[[1, 2], [1, 5]], [[2, 2], [2, 5]]]) - expect(editor.getSelections().length).toBe 2 - expect(editor.getCursors().length).toBe 2 - expect(editor.getCursors()[0].isVisible()).toBeTruthy() - expect(editor.getCursors()[1].isVisible()).toBeTruthy() - - describe "when showCursorOnSelection is false", -> - it "is visible while there is no selection", -> - editor.update({showCursorOnSelection: false}) - expect(selection.isEmpty()).toBeTruthy() - expect(editor.getShowCursorOnSelection()).toBeFalsy() - expect(editor.getCursors().length).toBe 1 - expect(editor.getCursors()[0].isVisible()).toBeTruthy() - - it "is not visible while there is a selection", -> - editor.update({showCursorOnSelection: false}) - expect(selection.isEmpty()).toBeTruthy() - editor.setSelectedBufferRange([[1, 2], [1, 5]]) - expect(selection.isEmpty()).toBeFalsy() - expect(editor.getCursors().length).toBe 1 - expect(editor.getCursors()[0].isVisible()).toBeFalsy() - - it "is not visible while there are multiple selections", -> - editor.update({showCursorOnSelection: false}) - expect(editor.getSelections().length).toBe 1 - editor.setSelectedBufferRanges([[[1, 2], [1, 5]], [[2, 2], [2, 5]]]) - expect(editor.getSelections().length).toBe 2 - expect(editor.getCursors().length).toBe 2 - expect(editor.getCursors()[0].isVisible()).toBeFalsy() - expect(editor.getCursors()[1].isVisible()).toBeFalsy() - it "does not share selections between different edit sessions for the same buffer", -> editor2 = null waitsForPromise -> @@ -3279,7 +3227,6 @@ describe "TextEditor", -> expect(line).toBe " var ort = function(items) {" expect(editor.getCursorScreenPosition()).toEqual {row: 1, column: 6} expect(changeScreenRangeHandler).toHaveBeenCalled() - expect(editor.getLastCursor().isVisible()).toBeTruthy() describe "when the cursor is at the beginning of a line", -> it "joins it with the line above", -> diff --git a/src/decoration-manager.js b/src/decoration-manager.js index 7a9269cae29..06dd3f2f5c0 100644 --- a/src/decoration-manager.js +++ b/src/decoration-manager.js @@ -145,7 +145,7 @@ class DecorationManager { const bufferRange = marker.getBufferRange() const rangeIsReversed = marker.isReversed() - const decorations = this.decorationsByMarker.get(marker.id) + const decorations = this.decorationsByMarker.get(marker) if (decorations) { decorations.forEach((decoration) => { decorationsState[decoration.id] = { From f3c48c8b70a7dafbc118cc55bb2d55d091f41e5d Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 13 Apr 2017 15:10:56 +0200 Subject: [PATCH 225/403] Register style elements change events in AtomEnvironment.initialize ...and fix spec/workspace-element-spec.js --- spec/workspace-element-spec.js | 10 +++++++--- src/atom-environment.coffee | 10 +++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/spec/workspace-element-spec.js b/spec/workspace-element-spec.js index e35907e650a..ea597c0aa54 100644 --- a/spec/workspace-element-spec.js +++ b/spec/workspace-element-spec.js @@ -230,28 +230,32 @@ describe('WorkspaceElement', () => { editorElement = editor.getElement() }) - it("updates the font-size based on the 'editor.fontSize' config value", () => { + it("updates the font-size based on the 'editor.fontSize' config value", async () => { const initialCharWidth = editor.getDefaultCharWidth() expect(getComputedStyle(editorElement).fontSize).toBe(atom.config.get('editor.fontSize') + 'px') + atom.config.set('editor.fontSize', atom.config.get('editor.fontSize') + 5) + await editorElement.component.getNextUpdatePromise() expect(getComputedStyle(editorElement).fontSize).toBe(atom.config.get('editor.fontSize') + 'px') expect(editor.getDefaultCharWidth()).toBeGreaterThan(initialCharWidth) }) - it("updates the font-family based on the 'editor.fontFamily' config value", () => { + it("updates the font-family based on the 'editor.fontFamily' config value", async () => { const initialCharWidth = editor.getDefaultCharWidth() let fontFamily = atom.config.get('editor.fontFamily') expect(getComputedStyle(editorElement).fontFamily).toBe(fontFamily) atom.config.set('editor.fontFamily', 'sans-serif') fontFamily = atom.config.get('editor.fontFamily') + await editorElement.component.getNextUpdatePromise() expect(getComputedStyle(editorElement).fontFamily).toBe(fontFamily) expect(editor.getDefaultCharWidth()).not.toBe(initialCharWidth) }) - it("updates the line-height based on the 'editor.lineHeight' config value", () => { + it("updates the line-height based on the 'editor.lineHeight' config value", async () => { const initialLineHeight = editor.getLineHeightInPixels() atom.config.set('editor.lineHeight', '30px') + await editorElement.component.getNextUpdatePromise() expect(getComputedStyle(editorElement).lineHeight).toBe(atom.config.get('editor.lineHeight')) expect(editor.getLineHeightInPixels()).not.toBe(initialLineHeight) }) diff --git a/src/atom-environment.coffee b/src/atom-environment.coffee index 06d5331ff19..7ecb9dd44ec 100644 --- a/src/atom-environment.coffee +++ b/src/atom-environment.coffee @@ -250,6 +250,11 @@ class AtomEnvironment extends Model @attachSaveStateListeners() @windowEventHandler.initialize(@window, @document) + didChangeStyles = @didChangeStyles.bind(this) + @disposables.add(@styles.onDidAddStyleElement(didChangeStyles)) + @disposables.add(@styles.onDidUpdateStyleElement(didChangeStyles)) + @disposables.add(@styles.onDidRemoveStyleElement(didChangeStyles)) + @observeAutoHideMenuBar() @history.initialize(@window.localStorage) @@ -697,11 +702,6 @@ class AtomEnvironment extends Model callback = => @applicationDelegate.didSaveWindowState() @saveState({isUnloading: true}).catch(callback).then(callback) - didChangeStyles = @didChangeStyles.bind(this) - @disposables.add(@styles.onDidAddStyleElement(didChangeStyles)) - @disposables.add(@styles.onDidUpdateStyleElement(didChangeStyles)) - @disposables.add(@styles.onDidRemoveStyleElement(didChangeStyles)) - @listenForUpdates() @registerDefaultTargetForKeymaps() From 5df17f061ecf384586137119a78af711d44a629e Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 13 Apr 2017 16:42:47 +0200 Subject: [PATCH 226/403] Create resize detector before calling etch.updateSync for the first time --- src/text-editor-component.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index cd8ebcb7705..d25483d234d 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -77,6 +77,7 @@ class TextEditorComponent { this.virtualNode = $('atom-text-editor') this.virtualNode.domNode = this.element this.refs = {} + this.resizeDetector = ResizeDetector({strategy: 'scroll'}) this.updateSync = this.updateSync.bind(this) this.didScrollDummyScrollbar = this.didScrollDummyScrollbar.bind(this) @@ -138,7 +139,6 @@ class TextEditorComponent { etch.updateSync(this) this.observeModel() - this.resizeDetector = ResizeDetector({strategy: 'scroll'}) this.resizeDetector.listenTo(this.element, this.didResize.bind(this)) if (this.refs.gutterContainer) { this.resizeDetector.listenTo(this.refs.gutterContainer, this.didResizeGutterContainer.bind(this)) From 0a702d1680095e4a9bfc8422bb75d43ea39030c7 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 13 Apr 2017 17:23:44 +0200 Subject: [PATCH 227/403] Skip obsolete tests for now, but delete them later --- spec/custom-gutter-component-spec.coffee | 2 +- spec/gutter-container-component-spec.coffee | 2 +- spec/lines-yardstick-spec.coffee | 2 +- spec/text-editor-presenter-spec.coffee | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/custom-gutter-component-spec.coffee b/spec/custom-gutter-component-spec.coffee index 731e7bfeb13..93b5413028d 100644 --- a/spec/custom-gutter-component-spec.coffee +++ b/spec/custom-gutter-component-spec.coffee @@ -1,7 +1,7 @@ CustomGutterComponent = require '../src/custom-gutter-component' Gutter = require '../src/gutter' -describe "CustomGutterComponent", -> +xdescribe "CustomGutterComponent", -> [gutterComponent, gutter] = [] beforeEach -> diff --git a/spec/gutter-container-component-spec.coffee b/spec/gutter-container-component-spec.coffee index b62485cad31..b09bf009a96 100644 --- a/spec/gutter-container-component-spec.coffee +++ b/spec/gutter-container-component-spec.coffee @@ -2,7 +2,7 @@ Gutter = require '../src/gutter' GutterContainerComponent = require '../src/gutter-container-component' DOMElementPool = require '../src/dom-element-pool' -describe "GutterContainerComponent", -> +xdescribe "GutterContainerComponent", -> [gutterContainerComponent] = [] mockGutterContainer = {} diff --git a/spec/lines-yardstick-spec.coffee b/spec/lines-yardstick-spec.coffee index 68fd7480412..61e09335ead 100644 --- a/spec/lines-yardstick-spec.coffee +++ b/spec/lines-yardstick-spec.coffee @@ -2,7 +2,7 @@ LinesYardstick = require '../src/lines-yardstick' LineTopIndex = require 'line-top-index' {Point} = require 'text-buffer' -describe "LinesYardstick", -> +xdescribe "LinesYardstick", -> [editor, mockLineNodesProvider, createdLineNodes, linesYardstick, buildLineNode] = [] beforeEach -> diff --git a/spec/text-editor-presenter-spec.coffee b/spec/text-editor-presenter-spec.coffee index 2c4b6dbab1b..2b382b9383d 100644 --- a/spec/text-editor-presenter-spec.coffee +++ b/spec/text-editor-presenter-spec.coffee @@ -7,7 +7,7 @@ TextEditorPresenter = require '../src/text-editor-presenter' FakeLinesYardstick = require './fake-lines-yardstick' LineTopIndex = require 'line-top-index' -describe "TextEditorPresenter", -> +xdescribe "TextEditorPresenter", -> # These `describe` and `it` blocks mirror the structure of the ::state object. # Please maintain this structure when adding specs for new state fields. describe "::get(Pre|Post)MeasurementState()", -> From 2a1ba7f05b33eb499f5fe7f0f3f9673e07a90f1c Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 13 Apr 2017 18:06:14 +0200 Subject: [PATCH 228/403] Add data-grammar to editor element --- spec/text-editor-component-spec.js | 11 +++++++++++ src/text-editor-component.js | 7 +++++++ 2 files changed, 18 insertions(+) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 818eb78f4ee..2d58a291d4d 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -410,6 +410,17 @@ describe('TextEditorComponent', () => { const {element} = buildComponent({placeholderText, text: ''}) expect(element.textContent).toContain(placeholderText) }) + + it('adds the data-grammar attribute and updates it when the grammar changes', async () => { + await atom.packages.activatePackage('language-javascript') + + const {editor, element, component} = buildComponent() + expect(element.dataset.grammar).toBe('text plain null-grammar') + + editor.setGrammar(atom.grammars.grammarForScopeName('source.js')) + await component.getNextUpdatePromise() + expect(element.dataset.grammar).toBe('source js') + }) }) describe('mini editors', () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index d25483d234d..60c1e2ea24a 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -361,11 +361,18 @@ class TextEditorComponent { className = className + ' mini' } + const dataset = {} + const grammar = model.getGrammar() + if (grammar && grammar.scopeName) { + dataset.grammar = grammar.scopeName.replace(/\./g, ' ') + } + return $('atom-text-editor', { className, style, attributes, + dataset, tabIndex: -1, on: { focus: this.didFocus, From 26b9273e00f2910ad0ace261fc2f7fbb3e8113c6 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 13 Apr 2017 18:22:23 +0200 Subject: [PATCH 229/403] Add data-encoding to editor element Signed-off-by: Nathan Sobo --- spec/text-editor-component-spec.js | 9 +++++++++ src/text-editor-component.js | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 2d58a291d4d..5020e82b826 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -421,6 +421,15 @@ describe('TextEditorComponent', () => { await component.getNextUpdatePromise() expect(element.dataset.grammar).toBe('source js') }) + + it('adds the data-encoding attribute and updates it when the encoding changes', async () => { + const {editor, element, component} = buildComponent() + expect(element.dataset.encoding).toBe('utf8') + + editor.setEncoding('ascii') + await component.getNextUpdatePromise() + expect(element.dataset.encoding).toBe('ascii') + }) }) describe('mini editors', () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 60c1e2ea24a..15ec3654177 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -361,7 +361,7 @@ class TextEditorComponent { className = className + ' mini' } - const dataset = {} + const dataset = {encoding: model.getEncoding()} const grammar = model.getGrammar() if (grammar && grammar.scopeName) { dataset.grammar = grammar.scopeName.replace(/\./g, ' ') From 03702a1fe6ca824836c0027c1501314671f943ba Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 13 Apr 2017 18:22:51 +0200 Subject: [PATCH 230/403] Add deprecated shadow root property to editor element Signed-off-by: Nathan Sobo --- src/text-editor-element.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/text-editor-element.js b/src/text-editor-element.js index f53c696353a..2a0f46496c5 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -1,5 +1,6 @@ const {Emitter} = require('atom') const TextEditorComponent = require('./text-editor-component') +const dedent = require('dedent') class TextEditorElement extends HTMLElement { initialize (component) { @@ -8,6 +9,16 @@ class TextEditorElement extends HTMLElement { return this } + get shadowRoot () { + Grim.deprecate(dedent` + The contents of \`atom-text-editor\` elements are no longer encapsulated + within a shadow DOM boundary. Please, stop using \`shadowRoot\` and access + the editor contents directly instead. + `) + + return this + } + attachedCallback () { this.getComponent().didAttach() this.emitter.emit('did-attach') From 8372d08b49f5716d9541ba781e153dbc865319f1 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 13 Apr 2017 18:43:30 +0200 Subject: [PATCH 231/403] Don't share block decoration/character measurement vnodes across instances Signed-off-by: Nathan Sobo --- src/text-editor-component.js | 40 ++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 15ec3654177..61228036ec1 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -20,22 +20,6 @@ const MOUSE_DRAG_AUTOSCROLL_MARGIN = 40 const MOUSE_WHEEL_SCROLL_SENSITIVITY = 0.8 const CURSOR_BLINK_RESUME_DELAY = 300 const CURSOR_BLINK_PERIOD = 800 -const BLOCK_DECORATION_MEASUREMENT_AREA_VNODE = $.div({ - ref: 'blockDecorationMeasurementArea', - key: 'blockDecorationMeasurementArea', - style: { - contain: 'strict', - position: 'absolute', - visibility: 'hidden' - } -}) -const CHARACTER_MEASUREMENT_LINE_VNODE = $.div( - {key: 'characterMeasurementLine', ref: 'characterMeasurementLine', className: 'line dummy'}, - $.span({ref: 'normalWidthCharacterSpan'}, NORMAL_WIDTH_CHARACTER), - $.span({ref: 'doubleWidthCharacterSpan'}, DOUBLE_WIDTH_CHARACTER), - $.span({ref: 'halfWidthCharacterSpan'}, HALF_WIDTH_CHARACTER), - $.span({ref: 'koreanCharacterSpan'}, KOREAN_CHARACTER) -) function scaleMouseDragAutoscrollDelta (delta) { return Math.pow(delta / 3, 3) / 280 @@ -132,6 +116,22 @@ class TextEditorComponent { this.gutterContainerVnode = null this.cursorsVnode = null this.placeholderTextVnode = null + this.blockDecorationMeasurementAreaVnode = $.div({ + ref: 'blockDecorationMeasurementArea', + key: 'blockDecorationMeasurementArea', + style: { + contain: 'strict', + position: 'absolute', + visibility: 'hidden' + } + }) + this.characterMeasurementLineVnode = $.div( + {key: 'characterMeasurementLine', ref: 'characterMeasurementLine', className: 'line dummy'}, + $.span({ref: 'normalWidthCharacterSpan'}, NORMAL_WIDTH_CHARACTER), + $.span({ref: 'doubleWidthCharacterSpan'}, DOUBLE_WIDTH_CHARACTER), + $.span({ref: 'halfWidthCharacterSpan'}, HALF_WIDTH_CHARACTER), + $.span({ref: 'koreanCharacterSpan'}, KOREAN_CHARACTER) + ) this.queryGuttersToRender() this.queryMaxLineNumberDigits() @@ -520,14 +520,14 @@ class TextEditorComponent { children = [ this.renderCursorsAndInput(), this.renderLineTiles(), - BLOCK_DECORATION_MEASUREMENT_AREA_VNODE, - CHARACTER_MEASUREMENT_LINE_VNODE, + this.blockDecorationMeasurementAreaVnode, + this.characterMeasurementLineVnode, this.renderPlaceholderText() ] } else { children = [ - BLOCK_DECORATION_MEASUREMENT_AREA_VNODE, - CHARACTER_MEASUREMENT_LINE_VNODE + this.blockDecorationMeasurementAreaVnode, + this.characterMeasurementLineVnode ] } From 837871700da3b745bc4637186a79af8c5a5553db Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 13 Apr 2017 19:07:50 +0200 Subject: [PATCH 232/403] Position dummy line element absolutely and make it invisible Signed-off-by: Nathan Sobo --- src/text-editor-component.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 61228036ec1..e47b3e0aee2 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -126,7 +126,12 @@ class TextEditorComponent { } }) this.characterMeasurementLineVnode = $.div( - {key: 'characterMeasurementLine', ref: 'characterMeasurementLine', className: 'line dummy'}, + { + key: 'characterMeasurementLine', + ref: 'characterMeasurementLine', + className: 'line dummy', + style: {position: 'absolute', visibility: 'hidden'} + }, $.span({ref: 'normalWidthCharacterSpan'}, NORMAL_WIDTH_CHARACTER), $.span({ref: 'doubleWidthCharacterSpan'}, DOUBLE_WIDTH_CHARACTER), $.span({ref: 'halfWidthCharacterSpan'}, HALF_WIDTH_CHARACTER), From 893da22c5594eb2b740f7c0ec66608a7dcd8bf9a Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 13 Apr 2017 19:53:38 +0200 Subject: [PATCH 233/403] Replace element-resize-detector with experimental ResizeObserver API Signed-off-by: Nathan Sobo --- package.json | 1 - src/text-editor-component.js | 36 ++++++++++++++++++++++++++++-------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 60b447ce8b5..b058370503f 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ "color": "^0.7.3", "dedent": "^0.6.0", "devtron": "1.3.0", - "element-resize-detector": "^1.1.10", "etch": "^0.12.0", "event-kit": "^2.3.0", "find-parent-dir": "^0.3.0", diff --git a/src/text-editor-component.js b/src/text-editor-component.js index e47b3e0aee2..aeb2d576f1d 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1,7 +1,6 @@ const etch = require('etch') const {CompositeDisposable} = require('event-kit') const {Point, Range} = require('text-buffer') -const ResizeDetector = require('element-resize-detector') const LineTopIndex = require('line-top-index') const TextEditor = require('./text-editor') const {isPairedCharacter} = require('./text-utils') @@ -61,7 +60,6 @@ class TextEditorComponent { this.virtualNode = $('atom-text-editor') this.virtualNode.domNode = this.element this.refs = {} - this.resizeDetector = ResizeDetector({strategy: 'scroll'}) this.updateSync = this.updateSync.bind(this) this.didScrollDummyScrollbar = this.didScrollDummyScrollbar.bind(this) @@ -144,10 +142,6 @@ class TextEditorComponent { etch.updateSync(this) this.observeModel() - this.resizeDetector.listenTo(this.element, this.didResize.bind(this)) - if (this.refs.gutterContainer) { - this.resizeDetector.listenTo(this.refs.gutterContainer, this.didResizeGutterContainer.bind(this)) - } } update (props) { @@ -772,7 +766,7 @@ class TextEditorComponent { renderOverlayDecorations () { return this.decorationsToRender.overlays.map((overlayProps) => $(OverlayComponent, Object.assign( - {key: overlayProps.element, resizeDetector: this.resizeDetector, didResize: this.updateSync}, + {key: overlayProps.element, didResize: () => { this.updateSync() }}, overlayProps )) ) @@ -1147,6 +1141,15 @@ class TextEditorComponent { } }) this.intersectionObserver.observe(this.element) + + this.resizeObserver = new ResizeObserver(this.didResize.bind(this)) + this.resizeObserver.observe(this.element) + + if (this.refs.gutterContainer) { + this.gutterContainerResizeObserver = new ResizeObserver(this.didResizeGutterContainer.bind(this)) + this.gutterContainerResizeObserver.observe(this.refs.gutterContainer) + } + if (this.isVisible()) { this.didShow() } else { @@ -1161,6 +1164,10 @@ class TextEditorComponent { didDetach () { if (this.attached) { + this.intersectionObserver.disconnect() + this.resizeObserver.disconnect() + if (this.gutterContainerResizeObserver) this.gutterContainerResizeObserver.disconnect() + this.didHide() this.attached = false this.constructor.attachedComponents.delete(this) @@ -3191,7 +3198,20 @@ class OverlayComponent { this.element.style.zIndex = 4 this.element.style.top = (this.props.pixelTop || 0) + 'px' this.element.style.left = (this.props.pixelLeft || 0) + 'px' - this.props.resizeDetector.listenTo(this.element, this.props.didResize) + + // Synchronous DOM updates in response to resize events might trigger a + // "loop limit exceeded" error. We disconnect the observer before + // potentially mutating the DOM, and then reconnect it on the next tick. + this.resizeObserver = new ResizeObserver(() => { + this.resizeObserver.disconnect() + this.props.didResize() + process.nextTick(() => { this.resizeObserver.observe(this.element) }) + }) + this.resizeObserver.observe(this.element) + } + + destroy () { + this.resizeObserver.disconnect() } update (newProps) { From 9bf0ea83f4f2541225bfdea720f6a0ebdf72d3fb Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 12 Apr 2017 14:22:04 -0600 Subject: [PATCH 234/403] Test clicking more locations outside of the lines --- spec/text-editor-component-spec.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 5020e82b826..5123c2db053 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1548,6 +1548,25 @@ describe('TextEditorComponent', () => { const {component, element, editor} = buildComponent() const {lineHeight} = component.measurements + editor.setCursorScreenPosition([Infinity, Infinity], {autoscroll: false}) + component.didMouseDownOnContent({ + detail: 1, + button: 0, + clientX: clientLeftForCharacter(component, 0, 0) - 1, + clientY: clientTopForLine(component, 0) - 1 + }) + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + + const maxRow = editor.getLastScreenRow() + editor.setCursorScreenPosition([Infinity, Infinity], {autoscroll: false}) + component.didMouseDownOnContent({ + detail: 1, + button: 0, + clientX: clientLeftForCharacter(component, maxRow, editor.lineLengthForScreenRow(maxRow)) + 1, + clientY: clientTopForLine(component, maxRow) + 1 + }) + expect(editor.getCursorScreenPosition()).toEqual([maxRow, editor.lineLengthForScreenRow(maxRow)]) + component.didMouseDownOnContent({ detail: 1, button: 0, From 988118213d512fbfe9ca8e42b430f24fc6a642b5 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 13 Apr 2017 12:19:56 -0600 Subject: [PATCH 235/403] Don't use position: relative on atom-text-editor --- static/text-editor.less | 2 -- 1 file changed, 2 deletions(-) diff --git a/static/text-editor.less b/static/text-editor.less index 90164d04022..ab53762fba1 100644 --- a/static/text-editor.less +++ b/static/text-editor.less @@ -1,8 +1,6 @@ @import "octicon-mixins.less"; atom-text-editor { - position: relative; - .gutter-container { float: left; width: min-content; From e602b5c46663ba751bb4a0ce27a6ffbb31696081 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 13 Apr 2017 14:16:59 -0600 Subject: [PATCH 236/403] Account for scrollbars and padding in autoHeight/Width mode --- spec/text-editor-component-spec.js | 41 +++++++++++++++++++++++++----- src/text-editor-component.js | 39 ++++++++++++++-------------- 2 files changed, 55 insertions(+), 25 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 5123c2db053..98ecbfaf020 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -346,7 +346,7 @@ describe('TextEditorComponent', () => { editor.setSoftWrapped(true) jasmine.attachToDOM(element) - expect(getBaseCharacterWidth(component)).toBe(55) + expect(getEditorWidthInBaseCharacters(component)).toBe(55) expect(lineNodeForScreenRow(component, 3).textContent).toBe( ' var pivot = items.shift(), current, left = [], ' ) @@ -384,20 +384,49 @@ describe('TextEditorComponent', () => { it('resizes based on the content when the autoHeight and/or autoWidth options are true', async () => { const {component, element, editor} = buildComponent({autoHeight: true, autoWidth: true}) + const editorPadding = 3 + element.style.padding = editorPadding + 'px' const {gutterContainer, scrollContainer} = component.refs const initialWidth = element.offsetWidth const initialHeight = element.offsetHeight - expect(initialWidth).toBe(gutterContainer.offsetWidth + scrollContainer.scrollWidth) - expect(initialHeight).toBe(scrollContainer.scrollHeight) + expect(initialWidth).toBe(component.getGutterContainerWidth() + component.getContentWidth() + 2 * editorPadding) + expect(initialHeight).toBe(component.getContentHeight() + 2 * editorPadding) + + // When autoWidth is enabled, width adjusts to content editor.setCursorScreenPosition([6, Infinity]) editor.insertText('x'.repeat(50)) await component.getNextUpdatePromise() - expect(element.offsetWidth).toBe(gutterContainer.offsetWidth + scrollContainer.scrollWidth) + expect(element.offsetWidth).toBe(component.getGutterContainerWidth() + component.getContentWidth() + 2 * editorPadding) expect(element.offsetWidth).toBeGreaterThan(initialWidth) + + // When autoHeight is enabled, height adjusts to content editor.insertText('\n'.repeat(5)) await component.getNextUpdatePromise() - expect(element.offsetHeight).toBe(scrollContainer.scrollHeight) + expect(element.offsetHeight).toBe(component.getContentHeight() + 2 * editorPadding) expect(element.offsetHeight).toBeGreaterThan(initialHeight) + + // When a horizontal scrollbar is visible, autoHeight accounts for it + editor.update({autoWidth: false}) + await component.getNextUpdatePromise() + element.style.width = component.getGutterContainerWidth() + component.getContentHeight() - 20 + 'px' + await component.getNextUpdatePromise() + expect(component.isHorizontalScrollbarVisible()).toBe(true) + expect(component.isVerticalScrollbarVisible()).toBe(false) + expect(element.offsetHeight).toBe(component.getContentHeight() + component.getHorizontalScrollbarHeight() + 2 * editorPadding) + + // When a vertical scrollbar is visible, autoWidth accounts for it + editor.update({autoWidth: true, autoHeight: false}) + await component.getNextUpdatePromise() + element.style.height = component.getContentHeight() - 20 + await component.getNextUpdatePromise() + expect(component.isHorizontalScrollbarVisible()).toBe(false) + expect(component.isVerticalScrollbarVisible()).toBe(true) + expect(element.offsetWidth).toBe( + component.getGutterContainerWidth() + + component.getContentWidth() + + component.getVerticalScrollbarWidth() + + 2 * editorPadding + ) }) it('supports the isLineNumberGutterVisible parameter', () => { @@ -2327,7 +2356,7 @@ function buildComponent (params = {}) { return {component, element, editor} } -function getBaseCharacterWidth (component) { +function getEditorWidthInBaseCharacters (component) { return Math.round(component.getScrollContainerWidth() / component.getBaseCharacterWidth()) } diff --git a/src/text-editor-component.js b/src/text-editor-component.js index aeb2d576f1d..62c6a704b0c 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -340,14 +340,19 @@ class TextEditorComponent { style.contain = 'size' } + let clientContainerHeight = '100%' + let clientContainerWidth = '100%' if (this.measurements) { if (model.getAutoHeight()) { - style.height = this.getContentHeight() + 'px' - } else { - style.height = this.element.style.height + clientContainerHeight = this.getContentHeight() + if (this.isHorizontalScrollbarVisible()) clientContainerHeight += this.getHorizontalScrollbarHeight() + clientContainerHeight += 'px' } if (model.getAutoWidth()) { - style.width = this.getGutterContainerWidth() + this.getContentWidth() + 'px' + style.width = 'min-content' + clientContainerWidth = this.getGutterContainerWidth() + this.getContentWidth() + if (this.isVerticalScrollbarVisible()) clientContainerWidth += this.getVerticalScrollbarWidth() + clientContainerWidth += 'px' } else { style.width = this.element.style.width } @@ -387,8 +392,8 @@ class TextEditorComponent { contain: 'strict', overflow: 'hidden', backgroundColor: 'inherit', - width: '100%', - height: '100%' + height: clientContainerHeight, + width: clientContainerWidth } }, this.renderGutterContainer(), @@ -2113,25 +2118,21 @@ class TextEditorComponent { } isVerticalScrollbarVisible () { + if (this.props.model.getAutoHeight()) return false + if (this.getContentHeight() > this.getScrollContainerHeight()) return true return ( - this.getContentHeight() > this.getScrollContainerHeight() || - ( - this.getContentWidth() > this.getScrollContainerWidth() && - this.getContentHeight() > (this.getScrollContainerHeight() - this.getHorizontalScrollbarHeight()) - ) + this.getContentWidth() > this.getScrollContainerWidth() && + this.getContentHeight() > (this.getScrollContainerHeight() - this.getHorizontalScrollbarHeight()) ) } isHorizontalScrollbarVisible () { + if (this.props.model.getAutoWidth()) return false + if (this.props.model.isSoftWrapped()) return false + if (this.getContentWidth() > this.getScrollContainerWidth()) return true return ( - !this.props.model.isSoftWrapped() && - ( - this.getContentWidth() > this.getScrollContainerWidth() || - ( - this.getContentHeight() > this.getScrollContainerHeight() && - this.getContentWidth() > (this.getScrollContainerWidth() - this.getVerticalScrollbarWidth()) - ) - ) + this.getContentHeight() > this.getScrollContainerHeight() && + this.getContentWidth() > (this.getScrollContainerWidth() - this.getVerticalScrollbarWidth()) ) } From 336aa0f52194487f769402ece043e37ce3e68a49 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 13 Apr 2017 14:21:21 -0600 Subject: [PATCH 237/403] Hide scrollbars in mini editors --- spec/text-editor-component-spec.js | 14 ++++++++++++++ src/text-editor-component.js | 21 +++++++++++++++------ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 98ecbfaf020..f4324505bf5 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -485,6 +485,20 @@ describe('TextEditorComponent', () => { await component.getNextUpdatePromise() expect(element.querySelector('.line').classList.contains('cursor-line')).toBe(false) }) + + it('does not render scrollbars', async () => { + const {component, element, editor} = buildComponent({mini: true, autoHeight: false}) + await setEditorWidthInCharacters(component, 10) + await setEditorHeightInLines(component, 1) + + editor.setText('x'.repeat(20) + 'y'.repeat(20)) + await component.getNextUpdatePromise() + + expect(component.isHorizontalScrollbarVisible()).toBe(false) + expect(component.isVerticalScrollbarVisible()).toBe(false) + expect(component.refs.horizontalScrollbar).toBeUndefined() + expect(component.refs.verticalScrollbar).toBeUndefined() + }) }) describe('focus', () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 62c6a704b0c..1a58d124101 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -699,7 +699,7 @@ class TextEditorComponent { } renderDummyScrollbars () { - if (this.shouldRenderDummyScrollbars) { + if (this.shouldRenderDummyScrollbars && !this.props.model.isMini()) { let scrollHeight, scrollTop, horizontalScrollbarHeight let scrollWidth, scrollLeft, verticalScrollbarWidth, forceScrollbarVisible @@ -1812,8 +1812,13 @@ class TextEditorComponent { } measureScrollbarDimensions () { - this.measurements.verticalScrollbarWidth = this.refs.verticalScrollbar.getRealScrollbarWidth() - this.measurements.horizontalScrollbarHeight = this.refs.horizontalScrollbar.getRealScrollbarHeight() + if (this.props.model.isMini()) { + this.measurements.verticalScrollbarWidth = 0 + this.measurements.horizontalScrollbarHeight = 0 + } else { + this.measurements.verticalScrollbarWidth = this.refs.verticalScrollbar.getRealScrollbarWidth() + this.measurements.horizontalScrollbarHeight = this.refs.horizontalScrollbar.getRealScrollbarHeight() + } } measureLongestLineWidth () { @@ -2118,7 +2123,9 @@ class TextEditorComponent { } isVerticalScrollbarVisible () { - if (this.props.model.getAutoHeight()) return false + const {model} = this.props + if (model.isMini()) return false + if (model.getAutoHeight()) return false if (this.getContentHeight() > this.getScrollContainerHeight()) return true return ( this.getContentWidth() > this.getScrollContainerWidth() && @@ -2127,8 +2134,10 @@ class TextEditorComponent { } isHorizontalScrollbarVisible () { - if (this.props.model.getAutoWidth()) return false - if (this.props.model.isSoftWrapped()) return false + const {model} = this.props + if (model.isMini()) return false + if (model.getAutoWidth()) return false + if (model.isSoftWrapped()) return false if (this.getContentWidth() > this.getScrollContainerWidth()) return true return ( this.getContentHeight() > this.getScrollContainerHeight() && From 87eb16f5ed2474cd0da9cc62bb57bd88ad0a8d83 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sat, 15 Apr 2017 12:30:17 -0600 Subject: [PATCH 238/403] Fix clicking fold placeholders by ignoring pointer events on cursors div --- src/text-editor-component.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 1a58d124101..da50885c4b3 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -633,7 +633,8 @@ class TextEditorComponent { contain: 'strict', zIndex: 1, width: this.getScrollWidth() + 'px', - height: this.getScrollHeight() + 'px' + height: this.getScrollHeight() + 'px', + pointerEvents: 'none' } }, children) } From f83ad6bb7c1db3f1c166050dccf908ccc486f7c8 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sat, 15 Apr 2017 12:30:20 -0600 Subject: [PATCH 239/403] Give cursors at the end of lines the width of an 'x' character --- spec/text-editor-component-spec.js | 7 +++++++ src/text-editor-component.js | 10 ++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index f4324505bf5..400af0a4231 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -320,6 +320,13 @@ describe('TextEditorComponent', () => { expect(getComputedStyle(cursor2).opacity).toBe('1') }) + it('gives cursors at the end of lines the width of an "x" character', async () => { + const {component, element, editor} = buildComponent() + editor.setCursorScreenPosition([0, Infinity]) + await component.getNextUpdatePromise() + expect(element.querySelector('.cursor').offsetWidth).toBe(Math.round(component.getBaseCharacterWidth())) + }) + it('places the hidden input element at the location of the last cursor if it is visible', async () => { const {component, element, editor} = buildComponent({height: 60, width: 120, rowsPerTile: 2}) const {hiddenInput} = component.refs diff --git a/src/text-editor-component.js b/src/text-editor-component.js index da50885c4b3..ded80019a85 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1086,10 +1086,12 @@ class TextEditorComponent { const pixelTop = this.pixelPositionAfterBlocksForRow(row) const pixelLeft = this.pixelLeftForRowAndColumn(row, column) - const pixelRight = (cursor.columnWidth === 0) - ? pixelLeft - : this.pixelLeftForRowAndColumn(row, column + 1) - const pixelWidth = pixelRight - pixelLeft + let pixelWidth + if (cursor.columnWidth === 0) { + pixelWidth = this.getBaseCharacterWidth() + } else { + pixelWidth = this.pixelLeftForRowAndColumn(row, column + 1) - pixelLeft + } const cursorPosition = {pixelTop, pixelLeft, pixelWidth} this.decorationsToRender.cursors[i] = cursorPosition From bfa410b11442e8011f5ba6c6e1e94cd283497b4f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sat, 15 Apr 2017 12:30:24 -0600 Subject: [PATCH 240/403] Add has-selection class to editors with non-empty selections --- spec/text-editor-component-spec.js | 19 +++++++++++++++++++ src/text-editor-component.js | 7 +++++++ 2 files changed, 26 insertions(+) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 400af0a4231..b77fedb4553 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -466,6 +466,25 @@ describe('TextEditorComponent', () => { await component.getNextUpdatePromise() expect(element.dataset.encoding).toBe('ascii') }) + + it('adds the has-selection class when the editor has a non-empty selection', async () => { + const {editor, element, component} = buildComponent() + expect(element.classList.contains('has-selection')).toBe(false) + + editor.setSelectedBufferRanges([ + [[0, 0], [0, 0]], + [[1, 0], [1, 10]] + ]) + await component.getNextUpdatePromise() + expect(element.classList.contains('has-selection')).toBe(true) + + editor.setSelectedBufferRanges([ + [[0, 0], [0, 0]], + [[1, 0], [1, 0]] + ]) + await component.getNextUpdatePromise() + expect(element.classList.contains('has-selection')).toBe(false) + }) }) describe('mini editors', () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index ded80019a85..73fec8f7e8a 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -365,6 +365,13 @@ class TextEditorComponent { className = className + ' mini' } + for (var i = 0; i < model.selections.length; i++) { + if (!model.selections[i].isEmpty()) { + className += ' has-selection' + break + } + } + const dataset = {encoding: model.getEncoding()} const grammar = model.getGrammar() if (grammar && grammar.scopeName) { From eb33b5c39b92c2e6072087ae5825342fe0f5ba17 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sat, 15 Apr 2017 12:30:28 -0600 Subject: [PATCH 241/403] Delete obsolete code and tests related to text editor rendering --- spec/custom-gutter-component-spec.coffee | 129 - spec/fake-lines-yardstick.coffee | 63 - spec/gutter-container-component-spec.coffee | 160 - spec/lines-yardstick-spec.coffee | 248 - spec/text-editor-component-spec-old.js | 5128 ----------------- spec/text-editor-presenter-spec.coffee | 3901 ------------- src/cursors-component.coffee | 58 - src/custom-gutter-component.coffee | 119 - src/gutter-container-component.coffee | 112 - src/highlights-component.coffee | 119 - src/input-component.coffee | 23 - src/line-number-gutter-component.coffee | 99 - src/line-numbers-tile-component.coffee | 158 - src/lines-component.coffee | 110 - src/lines-tile-component.js | 402 -- src/lines-yardstick.coffee | 133 - src/marker-observation-window.coffee | 12 - src/off-screen-block-decorations-component.js | 62 - src/scrollbar-component.coffee | 79 - src/scrollbar-corner-component.coffee | 38 - src/text-editor-component-old.coffee | 967 ---- src/text-editor-presenter.coffee | 1562 ----- 22 files changed, 13682 deletions(-) delete mode 100644 spec/custom-gutter-component-spec.coffee delete mode 100644 spec/fake-lines-yardstick.coffee delete mode 100644 spec/gutter-container-component-spec.coffee delete mode 100644 spec/lines-yardstick-spec.coffee delete mode 100644 spec/text-editor-component-spec-old.js delete mode 100644 spec/text-editor-presenter-spec.coffee delete mode 100644 src/cursors-component.coffee delete mode 100644 src/custom-gutter-component.coffee delete mode 100644 src/gutter-container-component.coffee delete mode 100644 src/highlights-component.coffee delete mode 100644 src/input-component.coffee delete mode 100644 src/line-number-gutter-component.coffee delete mode 100644 src/line-numbers-tile-component.coffee delete mode 100644 src/lines-component.coffee delete mode 100644 src/lines-tile-component.js delete mode 100644 src/lines-yardstick.coffee delete mode 100644 src/marker-observation-window.coffee delete mode 100644 src/off-screen-block-decorations-component.js delete mode 100644 src/scrollbar-component.coffee delete mode 100644 src/scrollbar-corner-component.coffee delete mode 100644 src/text-editor-component-old.coffee delete mode 100644 src/text-editor-presenter.coffee diff --git a/spec/custom-gutter-component-spec.coffee b/spec/custom-gutter-component-spec.coffee deleted file mode 100644 index 93b5413028d..00000000000 --- a/spec/custom-gutter-component-spec.coffee +++ /dev/null @@ -1,129 +0,0 @@ -CustomGutterComponent = require '../src/custom-gutter-component' -Gutter = require '../src/gutter' - -xdescribe "CustomGutterComponent", -> - [gutterComponent, gutter] = [] - - beforeEach -> - mockGutterContainer = {} - gutter = new Gutter(mockGutterContainer, {name: 'test-gutter'}) - gutterComponent = new CustomGutterComponent({gutter, views: atom.views}) - - it "creates a gutter DOM node with only an empty 'custom-decorations' child node when it is initialized", -> - expect(gutterComponent.getDomNode().classList.contains('gutter')).toBe true - expect(gutterComponent.getDomNode().getAttribute('gutter-name')).toBe 'test-gutter' - expect(gutterComponent.getDomNode().children.length).toBe 1 - decorationsWrapperNode = gutterComponent.getDomNode().children.item(0) - expect(decorationsWrapperNode.classList.contains('custom-decorations')).toBe true - - it "makes its view accessible from the view registry", -> - expect(gutterComponent.getDomNode()).toBe gutter.getElement() - - it "hides its DOM node when ::hideNode is called, and shows its DOM node when ::showNode is called", -> - gutterComponent.hideNode() - expect(gutterComponent.getDomNode().style.display).toBe 'none' - gutterComponent.showNode() - expect(gutterComponent.getDomNode().style.display).toBe '' - - describe "::updateSync", -> - decorationItem1 = document.createElement('div') - - buildTestState = (customDecorations) -> - mockTestState = - content: if customDecorations then customDecorations else {} - styles: - scrollHeight: 100 - scrollTop: 10 - backgroundColor: 'black' - - mockTestState - - it "sets the custom-decoration wrapper's scrollHeight, scrollTop, and background color", -> - decorationsWrapperNode = gutterComponent.getDomNode().children.item(0) - expect(decorationsWrapperNode.style.height).toBe '' - expect(decorationsWrapperNode.style['-webkit-transform']).toBe '' - expect(decorationsWrapperNode.style.backgroundColor).toBe '' - - gutterComponent.updateSync(buildTestState({})) - expect(decorationsWrapperNode.style.height).not.toBe '' - expect(decorationsWrapperNode.style['-webkit-transform']).not.toBe '' - expect(decorationsWrapperNode.style.backgroundColor).not.toBe '' - - it "creates a new DOM node for a new decoration and adds it to the gutter at the right place", -> - customDecorations = - 'decoration-id-1': - top: 0 - height: 10 - item: decorationItem1 - class: 'test-class-1' - - gutterComponent.updateSync(buildTestState(customDecorations)) - decorationsWrapperNode = gutterComponent.getDomNode().children.item(0) - expect(decorationsWrapperNode.children.length).toBe 1 - - decorationNode = decorationsWrapperNode.children.item(0) - expect(decorationNode.style.top).toBe '0px' - expect(decorationNode.style.height).toBe '10px' - expect(decorationNode.classList.contains('test-class-1')).toBe true - expect(decorationNode.classList.contains('decoration')).toBe true - expect(decorationNode.children.length).toBe 1 - - decorationItem = decorationNode.children.item(0) - expect(decorationItem).toBe decorationItem1 - - it "updates the existing DOM node for a decoration that existed but has new properties", -> - initialCustomDecorations = - 'decoration-id-1': - top: 0 - height: 10 - item: decorationItem1 - class: 'test-class-1' - gutterComponent.updateSync(buildTestState(initialCustomDecorations)) - initialDecorationNode = gutterComponent.getDomNode().children.item(0).children.item(0) - - # Change the dimensions and item, remove the class. - decorationItem2 = document.createElement('div') - changedCustomDecorations = - 'decoration-id-1': - top: 10 - height: 20 - item: decorationItem2 - gutterComponent.updateSync(buildTestState(changedCustomDecorations)) - changedDecorationNode = gutterComponent.getDomNode().children.item(0).children.item(0) - expect(changedDecorationNode).toBe initialDecorationNode - expect(changedDecorationNode.style.top).toBe '10px' - expect(changedDecorationNode.style.height).toBe '20px' - expect(changedDecorationNode.classList.contains('test-class-1')).toBe false - expect(changedDecorationNode.classList.contains('decoration')).toBe true - expect(changedDecorationNode.children.length).toBe 1 - decorationItem = changedDecorationNode.children.item(0) - expect(decorationItem).toBe decorationItem2 - - # Remove the item, add a class. - changedCustomDecorations = - 'decoration-id-1': - top: 10 - height: 20 - class: 'test-class-2' - gutterComponent.updateSync(buildTestState(changedCustomDecorations)) - changedDecorationNode = gutterComponent.getDomNode().children.item(0).children.item(0) - expect(changedDecorationNode).toBe initialDecorationNode - expect(changedDecorationNode.style.top).toBe '10px' - expect(changedDecorationNode.style.height).toBe '20px' - expect(changedDecorationNode.classList.contains('test-class-2')).toBe true - expect(changedDecorationNode.classList.contains('decoration')).toBe true - expect(changedDecorationNode.children.length).toBe 0 - - it "removes any decorations that existed previously but aren't in the latest update", -> - customDecorations = - 'decoration-id-1': - top: 0 - height: 10 - class: 'test-class-1' - gutterComponent.updateSync(buildTestState(customDecorations)) - decorationsWrapperNode = gutterComponent.getDomNode().children.item(0) - expect(decorationsWrapperNode.children.length).toBe 1 - - emptyCustomDecorations = {} - gutterComponent.updateSync(buildTestState(emptyCustomDecorations)) - expect(decorationsWrapperNode.children.length).toBe 0 diff --git a/spec/fake-lines-yardstick.coffee b/spec/fake-lines-yardstick.coffee deleted file mode 100644 index c3396ff9fd8..00000000000 --- a/spec/fake-lines-yardstick.coffee +++ /dev/null @@ -1,63 +0,0 @@ -{Point} = require 'text-buffer' -{isPairedCharacter} = require '../src/text-utils' - -module.exports = -class FakeLinesYardstick - constructor: (@model, @lineTopIndex) -> - {@displayLayer} = @model - @characterWidthsByScope = {} - - getScopedCharacterWidth: (scopeNames, char) -> - @getScopedCharacterWidths(scopeNames)[char] - - getScopedCharacterWidths: (scopeNames) -> - scope = @characterWidthsByScope - for scopeName in scopeNames - scope[scopeName] ?= {} - scope = scope[scopeName] - scope.characterWidths ?= {} - scope.characterWidths - - setScopedCharacterWidth: (scopeNames, character, width) -> - @getScopedCharacterWidths(scopeNames)[character] = width - - pixelPositionForScreenPosition: (screenPosition) -> - screenPosition = Point.fromObject(screenPosition) - - targetRow = screenPosition.row - targetColumn = screenPosition.column - - top = @lineTopIndex.pixelPositionAfterBlocksForRow(targetRow) - left = 0 - column = 0 - - scopes = [] - startIndex = 0 - {tagCodes, lineText} = @model.screenLineForScreenRow(targetRow) - for tagCode in tagCodes - if @displayLayer.isOpenTagCode(tagCode) - scopes.push(@displayLayer.tagForCode(tagCode)) - else if @displayLayer.isCloseTagCode(tagCode) - scopes.splice(scopes.lastIndexOf(@displayLayer.tagForCode(tagCode)), 1) - else - text = lineText.substr(startIndex, tagCode) - startIndex += tagCode - characterWidths = @getScopedCharacterWidths(scopes) - - valueIndex = 0 - while valueIndex < text.length - if isPairedCharacter(text, valueIndex) - char = text[valueIndex...valueIndex + 2] - charLength = 2 - valueIndex += 2 - else - char = text[valueIndex] - charLength = 1 - valueIndex++ - - break if column is targetColumn - - left += characterWidths[char] ? @model.getDefaultCharWidth() unless char is '\0' - column += charLength - - {top, left} diff --git a/spec/gutter-container-component-spec.coffee b/spec/gutter-container-component-spec.coffee deleted file mode 100644 index b09bf009a96..00000000000 --- a/spec/gutter-container-component-spec.coffee +++ /dev/null @@ -1,160 +0,0 @@ -Gutter = require '../src/gutter' -GutterContainerComponent = require '../src/gutter-container-component' -DOMElementPool = require '../src/dom-element-pool' - -xdescribe "GutterContainerComponent", -> - [gutterContainerComponent] = [] - mockGutterContainer = {} - - buildTestState = (gutters) -> - styles = - scrollHeight: 100 - scrollTop: 10 - backgroundColor: 'black' - - mockTestState = {gutters: []} - for gutter in gutters - if gutter.name is 'line-number' - content = {maxLineNumberDigits: 10, lineNumbers: {}} - else - content = {} - mockTestState.gutters.push({gutter, styles, content, visible: gutter.visible}) - - mockTestState - - beforeEach -> - domElementPool = new DOMElementPool - mockEditor = {} - mockMouseDown = -> - gutterContainerComponent = new GutterContainerComponent({editor: mockEditor, onMouseDown: mockMouseDown, domElementPool, views: atom.views}) - - it "creates a DOM node with no child gutter nodes when it is initialized", -> - expect(gutterContainerComponent.getDomNode() instanceof HTMLElement).toBe true - expect(gutterContainerComponent.getDomNode().children.length).toBe 0 - - describe "when updated with state that contains a new line-number gutter", -> - it "adds a LineNumberGutterComponent to its children", -> - lineNumberGutter = new Gutter(mockGutterContainer, {name: 'line-number'}) - testState = buildTestState([lineNumberGutter]) - - expect(gutterContainerComponent.getDomNode().children.length).toBe 0 - gutterContainerComponent.updateSync(testState) - expect(gutterContainerComponent.getDomNode().children.length).toBe 1 - expectedGutterNode = gutterContainerComponent.getDomNode().children.item(0) - expect(expectedGutterNode.classList.contains('gutter')).toBe true - expectedLineNumbersNode = expectedGutterNode.children.item(0) - expect(expectedLineNumbersNode.classList.contains('line-numbers')).toBe true - - expect(gutterContainerComponent.getLineNumberGutterComponent().getDomNode()).toBe expectedGutterNode - - describe "when updated with state that contains a new custom gutter", -> - it "adds a CustomGutterComponent to its children", -> - customGutter = new Gutter(mockGutterContainer, {name: 'custom'}) - testState = buildTestState([customGutter]) - - expect(gutterContainerComponent.getDomNode().children.length).toBe 0 - gutterContainerComponent.updateSync(testState) - expect(gutterContainerComponent.getDomNode().children.length).toBe 1 - expectedGutterNode = gutterContainerComponent.getDomNode().children.item(0) - expect(expectedGutterNode.classList.contains('gutter')).toBe true - expectedCustomDecorationsNode = expectedGutterNode.children.item(0) - expect(expectedCustomDecorationsNode.classList.contains('custom-decorations')).toBe true - - describe "when updated with state that contains a new gutter that is not visible", -> - it "creates the gutter view but hides it, and unhides it when it is later updated to be visible", -> - customGutter = new Gutter(mockGutterContainer, {name: 'custom', visible: false}) - testState = buildTestState([customGutter]) - - gutterContainerComponent.updateSync(testState) - expect(gutterContainerComponent.getDomNode().children.length).toBe 1 - expectedCustomGutterNode = gutterContainerComponent.getDomNode().children.item(0) - expect(expectedCustomGutterNode.style.display).toBe 'none' - - customGutter.show() - testState = buildTestState([customGutter]) - gutterContainerComponent.updateSync(testState) - expect(gutterContainerComponent.getDomNode().children.length).toBe 1 - expectedCustomGutterNode = gutterContainerComponent.getDomNode().children.item(0) - expect(expectedCustomGutterNode.style.display).toBe '' - - describe "when updated with a gutter that already exists", -> - it "reuses the existing gutter view, instead of recreating it", -> - customGutter = new Gutter(mockGutterContainer, {name: 'custom'}) - testState = buildTestState([customGutter]) - - gutterContainerComponent.updateSync(testState) - expect(gutterContainerComponent.getDomNode().children.length).toBe 1 - expectedCustomGutterNode = gutterContainerComponent.getDomNode().children.item(0) - - testState = buildTestState([customGutter]) - gutterContainerComponent.updateSync(testState) - expect(gutterContainerComponent.getDomNode().children.length).toBe 1 - expect(gutterContainerComponent.getDomNode().children.item(0)).toBe expectedCustomGutterNode - - it "removes a gutter from the DOM if it does not appear in the latest state update", -> - lineNumberGutter = new Gutter(mockGutterContainer, {name: 'line-number'}) - testState = buildTestState([lineNumberGutter]) - gutterContainerComponent.updateSync(testState) - - expect(gutterContainerComponent.getDomNode().children.length).toBe 1 - testState = buildTestState([]) - gutterContainerComponent.updateSync(testState) - expect(gutterContainerComponent.getDomNode().children.length).toBe 0 - - describe "when updated with multiple gutters", -> - it "positions (and repositions) the gutters to match the order they appear in each state update", -> - lineNumberGutter = new Gutter(mockGutterContainer, {name: 'line-number'}) - customGutter1 = new Gutter(mockGutterContainer, {name: 'custom', priority: -100}) - testState = buildTestState([customGutter1, lineNumberGutter]) - - gutterContainerComponent.updateSync(testState) - expect(gutterContainerComponent.getDomNode().children.length).toBe 2 - expectedCustomGutterNode = gutterContainerComponent.getDomNode().children.item(0) - expect(expectedCustomGutterNode).toBe customGutter1.getElement() - expectedLineNumbersNode = gutterContainerComponent.getDomNode().children.item(1) - expect(expectedLineNumbersNode).toBe lineNumberGutter.getElement() - - # Add a gutter. - customGutter2 = new Gutter(mockGutterContainer, {name: 'custom2', priority: -10}) - testState = buildTestState([customGutter1, customGutter2, lineNumberGutter]) - gutterContainerComponent.updateSync(testState) - expect(gutterContainerComponent.getDomNode().children.length).toBe 3 - expectedCustomGutterNode1 = gutterContainerComponent.getDomNode().children.item(0) - expect(expectedCustomGutterNode1).toBe customGutter1.getElement() - expectedCustomGutterNode2 = gutterContainerComponent.getDomNode().children.item(1) - expect(expectedCustomGutterNode2).toBe customGutter2.getElement() - expectedLineNumbersNode = gutterContainerComponent.getDomNode().children.item(2) - expect(expectedLineNumbersNode).toBe lineNumberGutter.getElement() - - # Hide one gutter, reposition one gutter, remove one gutter; and add a new gutter. - customGutter2.hide() - customGutter3 = new Gutter(mockGutterContainer, {name: 'custom3', priority: 100}) - testState = buildTestState([customGutter2, customGutter1, customGutter3]) - gutterContainerComponent.updateSync(testState) - expect(gutterContainerComponent.getDomNode().children.length).toBe 3 - expectedCustomGutterNode2 = gutterContainerComponent.getDomNode().children.item(0) - expect(expectedCustomGutterNode2).toBe customGutter2.getElement() - expect(expectedCustomGutterNode2.style.display).toBe 'none' - expectedCustomGutterNode1 = gutterContainerComponent.getDomNode().children.item(1) - expect(expectedCustomGutterNode1).toBe customGutter1.getElement() - expectedCustomGutterNode3 = gutterContainerComponent.getDomNode().children.item(2) - expect(expectedCustomGutterNode3).toBe customGutter3.getElement() - - it "reorders correctly when prepending multiple gutters at once", -> - lineNumberGutter = new Gutter(mockGutterContainer, {name: 'line-number'}) - testState = buildTestState([lineNumberGutter]) - gutterContainerComponent.updateSync(testState) - expect(gutterContainerComponent.getDomNode().children.length).toBe 1 - expectedCustomGutterNode = gutterContainerComponent.getDomNode().children.item(0) - expect(expectedCustomGutterNode).toBe lineNumberGutter.getElement() - - # Prepend two gutters at once - customGutter1 = new Gutter(mockGutterContainer, {name: 'first', priority: -200}) - customGutter2 = new Gutter(mockGutterContainer, {name: 'second', priority: -100}) - testState = buildTestState([customGutter1, customGutter2, lineNumberGutter]) - gutterContainerComponent.updateSync(testState) - expect(gutterContainerComponent.getDomNode().children.length).toBe 3 - expectedCustomGutterNode1 = gutterContainerComponent.getDomNode().children.item(0) - expect(expectedCustomGutterNode1).toBe customGutter1.getElement() - expectedCustomGutterNode2 = gutterContainerComponent.getDomNode().children.item(1) - expect(expectedCustomGutterNode2).toBe customGutter2.getElement() diff --git a/spec/lines-yardstick-spec.coffee b/spec/lines-yardstick-spec.coffee deleted file mode 100644 index 61e09335ead..00000000000 --- a/spec/lines-yardstick-spec.coffee +++ /dev/null @@ -1,248 +0,0 @@ -LinesYardstick = require '../src/lines-yardstick' -LineTopIndex = require 'line-top-index' -{Point} = require 'text-buffer' - -xdescribe "LinesYardstick", -> - [editor, mockLineNodesProvider, createdLineNodes, linesYardstick, buildLineNode] = [] - - beforeEach -> - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - waitsForPromise -> - atom.workspace.open('sample.js').then (o) -> editor = o - - runs -> - createdLineNodes = [] - - buildLineNode = (screenRow) -> - startIndex = 0 - scopes = [] - screenLine = editor.screenLineForScreenRow(screenRow) - lineNode = document.createElement("div") - lineNode.style.whiteSpace = "pre" - for tagCode in screenLine.tagCodes when tagCode isnt 0 - if editor.displayLayer.isCloseTagCode(tagCode) - scopes.pop() - else if editor.displayLayer.isOpenTagCode(tagCode) - scopes.push(editor.displayLayer.tagForCode(tagCode)) - else - text = screenLine.lineText.substr(startIndex, tagCode) - startIndex += tagCode - - span = document.createElement("span") - span.className = scopes.join(' ').replace(/\.+/g, ' ') - span.textContent = text - lineNode.appendChild(span) - jasmine.attachToDOM(lineNode) - createdLineNodes.push(lineNode) - lineNode - - mockLineNodesProvider = - lineNodesById: {} - - lineIdForScreenRow: (screenRow) -> - editor.screenLineForScreenRow(screenRow)?.id - - lineNodeForScreenRow: (screenRow) -> - if id = @lineIdForScreenRow(screenRow) - @lineNodesById[id] ?= buildLineNode(screenRow) - - textNodesForScreenRow: (screenRow) -> - lineNode = @lineNodeForScreenRow(screenRow) - iterator = document.createNodeIterator(lineNode, NodeFilter.SHOW_TEXT) - textNodes = [] - textNodes.push(textNode) while textNode = iterator.nextNode() - textNodes - - editor.setLineHeightInPixels(14) - lineTopIndex = new LineTopIndex({defaultLineHeight: editor.getLineHeightInPixels()}) - linesYardstick = new LinesYardstick(editor, mockLineNodesProvider, lineTopIndex, atom.grammars) - - afterEach -> - lineNode.remove() for lineNode in createdLineNodes - atom.themes.removeStylesheet('test') - - describe "::pixelPositionForScreenPosition(screenPosition)", -> - it "converts screen positions to pixel positions", -> - atom.styles.addStyleSheet """ - * { - font-size: 12px; - font-family: monospace; - } - .syntax--function { - font-size: 16px - } - """ - - expect(linesYardstick.pixelPositionForScreenPosition(Point(0, 0))).toEqual({left: 0, top: 0}) - expect(linesYardstick.pixelPositionForScreenPosition(Point(0, 1))).toEqual({left: 7, top: 0}) - expect(linesYardstick.pixelPositionForScreenPosition(Point(0, 5))).toEqual({left: 38, top: 0}) - - switch process.platform - when 'darwin' - expect(linesYardstick.pixelPositionForScreenPosition(Point(1, 6))).toEqual({left: 43, top: 14}) - expect(linesYardstick.pixelPositionForScreenPosition(Point(1, 9))).toEqual({left: 72, top: 14}) - expect(linesYardstick.pixelPositionForScreenPosition(Point(2, Infinity))).toEqual({left: 287.875, top: 28}) - when 'win32' - expect(linesYardstick.pixelPositionForScreenPosition(Point(1, 6))).toEqual({left: 42, top: 14}) - expect(linesYardstick.pixelPositionForScreenPosition(Point(1, 9))).toEqual({left: 71, top: 14}) - expect(linesYardstick.pixelPositionForScreenPosition(Point(2, Infinity))).toEqual({left: 280, top: 28}) - - it "reuses already computed pixel positions unless it is invalidated", -> - atom.styles.addStyleSheet """ - * { - font-size: 16px; - font-family: monospace; - } - """ - - expect(linesYardstick.pixelPositionForScreenPosition(Point(1, 2))).toEqual({left: 19, top: 14}) - expect(linesYardstick.pixelPositionForScreenPosition(Point(2, 6))).toEqual({left: 57.609375, top: 28}) - expect(linesYardstick.pixelPositionForScreenPosition(Point(5, 10))).toEqual({left: 96, top: 70}) - - atom.styles.addStyleSheet """ - * { - font-size: 20px; - } - """ - - expect(linesYardstick.pixelPositionForScreenPosition(Point(1, 2))).toEqual({left: 19, top: 14}) - expect(linesYardstick.pixelPositionForScreenPosition(Point(2, 6))).toEqual({left: 57.609375, top: 28}) - expect(linesYardstick.pixelPositionForScreenPosition(Point(5, 10))).toEqual({left: 96, top: 70}) - - linesYardstick.invalidateCache() - - expect(linesYardstick.pixelPositionForScreenPosition(Point(1, 2))).toEqual({left: 24, top: 14}) - expect(linesYardstick.pixelPositionForScreenPosition(Point(2, 6))).toEqual({left: 72, top: 28}) - expect(linesYardstick.pixelPositionForScreenPosition(Point(5, 10))).toEqual({left: 120, top: 70}) - - it "doesn't report a width greater than 0 when the character to measure is at the beginning of a text node", -> - # This spec documents what seems to be a bug in Chromium, because we'd - # expect that Range(0, 0).getBoundingClientRect().width to always be zero. - atom.styles.addStyleSheet """ - * { - font-size: 11px; - font-family: monospace; - } - """ - - text = " \\vec{w}_j^r(\\text{new}) &= \\vec{w}_j^r(\\text{old}) + \\Delta\\vec{w}_j^r, \\\\" - buildLineNode = (screenRow) -> - lineNode = document.createElement("div") - lineNode.style.whiteSpace = "pre" - # We couldn't reproduce the problem with a simple string, so we're - # attaching the full one that comes from a bug report. - lineNode.innerHTML = ' \\vec{w}_j^r(\\text{new}) &= \\vec{w}_j^r(\\text{old}) + \\Delta\\vec{w}_j^r, \\\\' - jasmine.attachToDOM(lineNode) - createdLineNodes.push(lineNode) - lineNode - - editor.setText(text) - - switch process.platform - when 'darwin' - expect(linesYardstick.pixelPositionForScreenPosition(Point(0, 35)).left).toBe 230.90625 - expect(linesYardstick.pixelPositionForScreenPosition(Point(0, 36)).left).toBe 237.5 - expect(linesYardstick.pixelPositionForScreenPosition(Point(0, 37)).left).toBe 244.09375 - when 'win32' - expect(linesYardstick.pixelPositionForScreenPosition(Point(0, 35)).left).toBe 245 - expect(linesYardstick.pixelPositionForScreenPosition(Point(0, 36)).left).toBe 252 - expect(linesYardstick.pixelPositionForScreenPosition(Point(0, 37)).left).toBe 259 - - it "handles lines containing a mix of left-to-right and right-to-left characters", -> - editor.setText('Persian, locally known as Parsi or Farsi (زبان فارسی), the predominant modern descendant of Old Persian.\n') - - atom.styles.addStyleSheet """ - * { - font-size: 14px; - font-family: monospace; - } - """ - - lineTopIndex = new LineTopIndex({defaultLineHeight: editor.getLineHeightInPixels()}) - linesYardstick = new LinesYardstick(editor, mockLineNodesProvider, lineTopIndex, atom.grammars) - - switch process.platform - when 'darwin' - expect(linesYardstick.pixelPositionForScreenPosition(Point(0, 15))).toEqual({left: 126, top: 0}) - expect(linesYardstick.pixelPositionForScreenPosition(Point(0, 62))).toEqual({left: 521, top: 0}) - expect(linesYardstick.pixelPositionForScreenPosition(Point(0, 58))).toEqual({left: 487, top: 0}) - expect(linesYardstick.pixelPositionForScreenPosition(Point(0, Infinity))).toEqual({left: 873.625, top: 0}) - when 'win32' - expect(linesYardstick.pixelPositionForScreenPosition(Point(0, 15))).toEqual({left: 120, top: 0}) - expect(linesYardstick.pixelPositionForScreenPosition(Point(0, 62))).toEqual({left: 496, top: 0}) - expect(linesYardstick.pixelPositionForScreenPosition(Point(0, 58))).toEqual({left: 464, top: 0}) - expect(linesYardstick.pixelPositionForScreenPosition(Point(0, Infinity))).toEqual({left: 832, top: 0}) - - describe "::screenPositionForPixelPosition(pixelPosition)", -> - it "converts pixel positions to screen positions", -> - atom.styles.addStyleSheet """ - * { - font-size: 12px; - font-family: monospace; - } - .syntax--function { - font-size: 16px - } - """ - - expect(linesYardstick.screenPositionForPixelPosition({top: 0, left: 12.5})).toEqual([0, 2]) - expect(linesYardstick.screenPositionForPixelPosition({top: 14, left: 18.8})).toEqual([1, 3]) - expect(linesYardstick.screenPositionForPixelPosition({top: 28, left: 100})).toEqual([2, 14]) - expect(linesYardstick.screenPositionForPixelPosition({top: 32, left: 24.3})).toEqual([2, 3]) - expect(linesYardstick.screenPositionForPixelPosition({top: 46, left: 66.5})).toEqual([3, 9]) - expect(linesYardstick.screenPositionForPixelPosition({top: 70, left: 99.9})).toEqual([5, 14]) - expect(linesYardstick.screenPositionForPixelPosition({top: 70, left: 225})).toEqual([5, 30]) - - switch process.platform - when 'darwin' - expect(linesYardstick.screenPositionForPixelPosition({top: 70, left: 224.2365234375})).toEqual([5, 29]) - expect(linesYardstick.screenPositionForPixelPosition({top: 84, left: 247.1})).toEqual([6, 33]) - when 'win32' - expect(linesYardstick.screenPositionForPixelPosition({top: 70, left: 224.2365234375})).toEqual([5, 30]) - expect(linesYardstick.screenPositionForPixelPosition({top: 84, left: 247.1})).toEqual([6, 34]) - - it "overshoots to the nearest character when text nodes are not spatially contiguous", -> - atom.styles.addStyleSheet """ - * { - font-size: 12px; - font-family: monospace; - } - """ - - buildLineNode = (screenRow) -> - lineNode = document.createElement("div") - lineNode.style.whiteSpace = "pre" - lineNode.innerHTML = 'foobar' - jasmine.attachToDOM(lineNode) - createdLineNodes.push(lineNode) - lineNode - editor.setText("foobar") - - expect(linesYardstick.screenPositionForPixelPosition({top: 0, left: 7})).toEqual([0, 1]) - expect(linesYardstick.screenPositionForPixelPosition({top: 0, left: 14})).toEqual([0, 2]) - expect(linesYardstick.screenPositionForPixelPosition({top: 0, left: 21})).toEqual([0, 3]) - expect(linesYardstick.screenPositionForPixelPosition({top: 0, left: 30})).toEqual([0, 3]) - expect(linesYardstick.screenPositionForPixelPosition({top: 0, left: 50})).toEqual([0, 3]) - expect(linesYardstick.screenPositionForPixelPosition({top: 0, left: 62})).toEqual([0, 3]) - expect(linesYardstick.screenPositionForPixelPosition({top: 0, left: 69})).toEqual([0, 4]) - expect(linesYardstick.screenPositionForPixelPosition({top: 0, left: 76})).toEqual([0, 5]) - expect(linesYardstick.screenPositionForPixelPosition({top: 0, left: 100})).toEqual([0, 6]) - expect(linesYardstick.screenPositionForPixelPosition({top: 0, left: 200})).toEqual([0, 6]) - - it "clips pixel positions above buffer start", -> - expect(linesYardstick.screenPositionForPixelPosition(top: -Infinity, left: -Infinity)).toEqual [0, 0] - expect(linesYardstick.screenPositionForPixelPosition(top: -Infinity, left: Infinity)).toEqual [0, 0] - expect(linesYardstick.screenPositionForPixelPosition(top: -1, left: Infinity)).toEqual [0, 0] - expect(linesYardstick.screenPositionForPixelPosition(top: 0, left: Infinity)).toEqual [0, 29] - - it "clips pixel positions below buffer end", -> - expect(linesYardstick.screenPositionForPixelPosition(top: Infinity, left: -Infinity)).toEqual [12, 2] - expect(linesYardstick.screenPositionForPixelPosition(top: Infinity, left: Infinity)).toEqual [12, 2] - expect(linesYardstick.screenPositionForPixelPosition(top: (editor.getLastScreenRow() + 1) * 14, left: 0)).toEqual [12, 2] - expect(linesYardstick.screenPositionForPixelPosition(top: editor.getLastScreenRow() * 14, left: 0)).toEqual [12, 0] - - it "clips negative horizontal pixel positions", -> - expect(linesYardstick.screenPositionForPixelPosition(top: 0, left: -10)).toEqual [0, 0] - expect(linesYardstick.screenPositionForPixelPosition(top: 1 * 14, left: -10)).toEqual [1, 0] diff --git a/spec/text-editor-component-spec-old.js b/spec/text-editor-component-spec-old.js deleted file mode 100644 index e145bac9020..00000000000 --- a/spec/text-editor-component-spec-old.js +++ /dev/null @@ -1,5128 +0,0 @@ -/** @babel */ - -import {it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise} from './async-spec-helpers' -import Grim from 'grim' -import TextEditor from '../src/text-editor' -import TextEditorElement from '../src/text-editor-element' -import _, {extend, flatten, last, toArray} from 'underscore-plus' - -const NBSP = String.fromCharCode(160) -const TILE_SIZE = 3 - -describe('TextEditorComponent', function () { - let charWidth, component, componentNode, contentNode, editor, - horizontalScrollbarNode, lineHeightInPixels, tileHeightInPixels, - verticalScrollbarNode, wrapperNode, animationFrameRequests - - function runAnimationFrames (runFollowupFrames) { - if (runFollowupFrames) { - let fn - while (fn = animationFrameRequests.shift()) fn() - } else { - const requests = animationFrameRequests.slice() - animationFrameRequests = [] - for (let fn of requests) fn() - } - } - - beforeEach(async function () { - animationFrameRequests = [] - spyOn(window, 'requestAnimationFrame').andCallFake(function (fn) { animationFrameRequests.push(fn) }) - jasmine.useMockClock() - - await atom.packages.activatePackage('language-javascript') - editor = await atom.workspace.open('sample.js') - editor.update({autoHeight: true}) - - contentNode = document.querySelector('#jasmine-content') - contentNode.style.width = '1000px' - - wrapperNode = new TextEditorElement() - wrapperNode.tileSize = TILE_SIZE - wrapperNode.initialize(editor, atom) - wrapperNode.setUpdatedSynchronously(false) - jasmine.attachToDOM(wrapperNode) - - component = wrapperNode.component - component.setFontFamily('monospace') - component.setLineHeight(1.3) - component.setFontSize(20) - - lineHeightInPixels = editor.getLineHeightInPixels() - tileHeightInPixels = TILE_SIZE * lineHeightInPixels - charWidth = editor.getDefaultCharWidth() - - componentNode = component.getDomNode() - verticalScrollbarNode = componentNode.querySelector('.vertical-scrollbar') - horizontalScrollbarNode = componentNode.querySelector('.horizontal-scrollbar') - - component.measureDimensions() - runAnimationFrames(true) - }) - - afterEach(function () { - contentNode.style.width = '' - }) - - describe('async updates', function () { - it('handles corrupted state gracefully', function () { - editor.insertNewline() - component.presenter.startRow = -1 - component.presenter.endRow = 9999 - runAnimationFrames() // assert an update does occur - }) - - it('does not update when an animation frame was requested but the component got destroyed before its delivery', function () { - editor.setText('You should not see this update.') - component.destroy() - - runAnimationFrames() - - expect(component.lineNodeForScreenRow(0).textContent).not.toBe('You should not see this update.') - }) - }) - - describe('line rendering', function () { - function expectTileContainsRow (tileNode, screenRow, {top}) { - let lineNode = tileNode.querySelector('[data-screen-row="' + screenRow + '"]') - let text = editor.lineTextForScreenRow(screenRow) - expect(lineNode.offsetTop).toBe(top) - if (text === '') { - expect(lineNode.textContent).toBe(' ') - } else { - expect(lineNode.textContent).toBe(text) - } - } - - it('gives the lines container the same height as the wrapper node', function () { - let linesNode = componentNode.querySelector('.lines') - wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - expect(linesNode.getBoundingClientRect().height).toBe(6.5 * lineHeightInPixels) - wrapperNode.style.height = 3.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - - runAnimationFrames() - - expect(linesNode.getBoundingClientRect().height).toBe(3.5 * lineHeightInPixels) - }) - - it('renders higher tiles in front of lower ones', function () { - wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - - runAnimationFrames() - - let tilesNodes = component.tileNodesForLines() - expect(tilesNodes[0].style.zIndex).toBe('2') - expect(tilesNodes[1].style.zIndex).toBe('1') - expect(tilesNodes[2].style.zIndex).toBe('0') - verticalScrollbarNode.scrollTop = 1 * lineHeightInPixels - verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - runAnimationFrames(true) - - tilesNodes = component.tileNodesForLines() - expect(tilesNodes[0].style.zIndex).toBe('3') - expect(tilesNodes[1].style.zIndex).toBe('2') - expect(tilesNodes[2].style.zIndex).toBe('1') - expect(tilesNodes[3].style.zIndex).toBe('0') - }) - - it('renders the currently-visible lines in a tiled fashion', function () { - wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - - runAnimationFrames() - - let tilesNodes = component.tileNodesForLines() - expect(tilesNodes.length).toBe(3) - - expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, 0px, 0px)') - expect(tilesNodes[0].querySelectorAll('.line').length).toBe(TILE_SIZE) - expectTileContainsRow(tilesNodes[0], 0, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[0], 1, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[0], 2, { - top: 2 * lineHeightInPixels - }) - - expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels) + 'px, 0px)') - expect(tilesNodes[1].querySelectorAll('.line').length).toBe(TILE_SIZE) - expectTileContainsRow(tilesNodes[1], 3, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[1], 4, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[1], 5, { - top: 2 * lineHeightInPixels - }) - - expect(tilesNodes[2].style['-webkit-transform']).toBe('translate3d(0px, ' + (2 * tileHeightInPixels) + 'px, 0px)') - expect(tilesNodes[2].querySelectorAll('.line').length).toBe(TILE_SIZE) - expectTileContainsRow(tilesNodes[2], 6, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[2], 7, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[2], 8, { - top: 2 * lineHeightInPixels - }) - - expect(component.lineNodeForScreenRow(9)).toBeUndefined() - - verticalScrollbarNode.scrollTop = TILE_SIZE * lineHeightInPixels + 5 - verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - runAnimationFrames(true) - - tilesNodes = component.tileNodesForLines() - expect(component.lineNodeForScreenRow(2)).toBeUndefined() - expect(tilesNodes.length).toBe(3) - - expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, ' + (0 * tileHeightInPixels - 5) + 'px, 0px)') - expect(tilesNodes[0].querySelectorAll('.line').length).toBe(TILE_SIZE) - expectTileContainsRow(tilesNodes[0], 3, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[0], 4, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[0], 5, { - top: 2 * lineHeightInPixels - }) - - expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels - 5) + 'px, 0px)') - expect(tilesNodes[1].querySelectorAll('.line').length).toBe(TILE_SIZE) - expectTileContainsRow(tilesNodes[1], 6, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[1], 7, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[1], 8, { - top: 2 * lineHeightInPixels - }) - - expect(tilesNodes[2].style['-webkit-transform']).toBe('translate3d(0px, ' + (2 * tileHeightInPixels - 5) + 'px, 0px)') - expect(tilesNodes[2].querySelectorAll('.line').length).toBe(TILE_SIZE) - expectTileContainsRow(tilesNodes[2], 9, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[2], 10, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[2], 11, { - top: 2 * lineHeightInPixels - }) - }) - - it('updates the top position of subsequent tiles when lines are inserted or removed', function () { - wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - editor.getBuffer().deleteRows(0, 1) - - runAnimationFrames() - - let tilesNodes = component.tileNodesForLines() - expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, 0px, 0px)') - expectTileContainsRow(tilesNodes[0], 0, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[0], 1, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[0], 2, { - top: 2 * lineHeightInPixels - }) - - expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels) + 'px, 0px)') - expectTileContainsRow(tilesNodes[1], 3, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[1], 4, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[1], 5, { - top: 2 * lineHeightInPixels - }) - - editor.getBuffer().insert([0, 0], '\n\n') - - runAnimationFrames() - - tilesNodes = component.tileNodesForLines() - expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, 0px, 0px)') - expectTileContainsRow(tilesNodes[0], 0, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[0], 1, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[0], 2, { - top: 2 * lineHeightInPixels - }) - - expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels) + 'px, 0px)') - expectTileContainsRow(tilesNodes[1], 3, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[1], 4, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[1], 5, { - top: 2 * lineHeightInPixels - }) - - expect(tilesNodes[2].style['-webkit-transform']).toBe('translate3d(0px, ' + (2 * tileHeightInPixels) + 'px, 0px)') - expectTileContainsRow(tilesNodes[2], 6, { - top: 0 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[2], 7, { - top: 1 * lineHeightInPixels - }) - expectTileContainsRow(tilesNodes[2], 8, { - top: 2 * lineHeightInPixels - }) - }) - - it('updates the lines when lines are inserted or removed above the rendered row range', function () { - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - verticalScrollbarNode.scrollTop = 5 * lineHeightInPixels - verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - runAnimationFrames(true) - - let buffer = editor.getBuffer() - buffer.insert([0, 0], '\n\n') - - runAnimationFrames() - - expect(component.lineNodeForScreenRow(3).textContent).toBe(editor.lineTextForScreenRow(3)) - buffer.delete([[0, 0], [3, 0]]) - - runAnimationFrames() - - expect(component.lineNodeForScreenRow(3).textContent).toBe(editor.lineTextForScreenRow(3)) - }) - - it('updates the top position of lines when the line height changes', function () { - let initialLineHeightInPixels = editor.getLineHeightInPixels() - - component.setLineHeight(2) - - runAnimationFrames() - - let newLineHeightInPixels = editor.getLineHeightInPixels() - expect(newLineHeightInPixels).not.toBe(initialLineHeightInPixels) - expect(component.lineNodeForScreenRow(1).offsetTop).toBe(1 * newLineHeightInPixels) - }) - - it('updates the top position of lines when the font size changes', function () { - let initialLineHeightInPixels = editor.getLineHeightInPixels() - component.setFontSize(10) - - runAnimationFrames() - - let newLineHeightInPixels = editor.getLineHeightInPixels() - expect(newLineHeightInPixels).not.toBe(initialLineHeightInPixels) - expect(component.lineNodeForScreenRow(1).offsetTop).toBe(1 * newLineHeightInPixels) - }) - - it('renders the .lines div at the full height of the editor if there are not enough lines to scroll vertically', function () { - editor.setText('') - wrapperNode.style.height = '300px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - let linesNode = componentNode.querySelector('.lines') - expect(linesNode.offsetHeight).toBe(300) - }) - - it('assigns the width of each line so it extends across the full width of the editor', function () { - let gutterWidth = componentNode.querySelector('.gutter').offsetWidth - let scrollViewNode = componentNode.querySelector('.scroll-view') - let lineNodes = Array.from(componentNode.querySelectorAll('.line')) - - componentNode.style.width = gutterWidth + (30 * charWidth) + 'px' - component.measureDimensions() - - runAnimationFrames() - - expect(wrapperNode.getScrollWidth()).toBeGreaterThan(scrollViewNode.offsetWidth) - let editorFullWidth = wrapperNode.getScrollWidth() + wrapperNode.getVerticalScrollbarWidth() - for (let lineNode of lineNodes) { - expect(lineNode.getBoundingClientRect().width).toBe(editorFullWidth) - } - - componentNode.style.width = gutterWidth + wrapperNode.getScrollWidth() + 100 + 'px' - component.measureDimensions() - - runAnimationFrames() - - let scrollViewWidth = scrollViewNode.offsetWidth - for (let lineNode of lineNodes) { - expect(lineNode.getBoundingClientRect().width).toBe(scrollViewWidth) - } - }) - - it('renders a placeholder space on empty lines when no line-ending character is defined', function () { - editor.update({showInvisibles: false}) - expect(component.lineNodeForScreenRow(10).textContent).toBe(' ') - }) - - it('gives the lines and tiles divs the same background color as the editor to improve GPU performance', async function () { - let linesNode = componentNode.querySelector('.lines') - let backgroundColor = getComputedStyle(wrapperNode).backgroundColor - - expect(getComputedStyle(linesNode).backgroundColor).toBe(backgroundColor) - for (let tileNode of component.tileNodesForLines()) { - expect(getComputedStyle(tileNode).backgroundColor).toBe(backgroundColor) - } - - wrapperNode.style.backgroundColor = 'rgb(255, 0, 0)' - expect(getComputedStyle(linesNode).backgroundColor).toBe('rgb(255, 0, 0)') - for (let tileNode of component.tileNodesForLines()) { - expect(getComputedStyle(tileNode).backgroundColor).toBe('rgb(255, 0, 0)') - } - }) - - it('applies .leading-whitespace for lines with leading spaces and/or tabs', function () { - editor.setText(' a') - - runAnimationFrames() - - let leafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) - expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(true) - expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(false) - - editor.setText('\ta') - runAnimationFrames() - - leafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) - expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(true) - expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(false) - }) - - it('applies .trailing-whitespace for lines with trailing spaces and/or tabs', function () { - editor.setText(' ') - runAnimationFrames() - - let leafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) - expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(true) - expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(false) - - editor.setText('\t') - runAnimationFrames() - - leafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) - expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(true) - expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(false) - editor.setText('a ') - runAnimationFrames() - - leafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) - expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(true) - expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(false) - editor.setText('a\t') - runAnimationFrames() - - leafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) - expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(true) - expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(false) - }) - - it('keeps rebuilding lines when continuous reflow is on', function () { - wrapperNode.setContinuousReflow(true) - let oldLineNode = componentNode.querySelectorAll('.line')[1] - - while (true) { - advanceClock(component.presenter.minimumReflowInterval) - runAnimationFrames() - if (componentNode.querySelectorAll('.line')[1] !== oldLineNode) break - } - }) - - describe('when showInvisibles is enabled', function () { - const invisibles = { - eol: 'E', - space: 'S', - tab: 'T', - cr: 'C' - } - - beforeEach(function () { - editor.update({ - showInvisibles: true, - invisibles: invisibles - }) - runAnimationFrames() - }) - - it('re-renders the lines when the showInvisibles config option changes', function () { - editor.setText(' a line with tabs\tand spaces \n') - runAnimationFrames() - - expect(component.lineNodeForScreenRow(0).textContent).toBe('' + invisibles.space + 'a line with tabs' + invisibles.tab + 'and spaces' + invisibles.space + invisibles.eol) - - editor.update({showInvisibles: false}) - runAnimationFrames() - - expect(component.lineNodeForScreenRow(0).textContent).toBe(' a line with tabs and spaces ') - - editor.update({showInvisibles: true}) - runAnimationFrames() - - expect(component.lineNodeForScreenRow(0).textContent).toBe('' + invisibles.space + 'a line with tabs' + invisibles.tab + 'and spaces' + invisibles.space + invisibles.eol) - }) - - it('displays leading/trailing spaces, tabs, and newlines as visible characters', function () { - editor.setText(' a line with tabs\tand spaces \n') - - runAnimationFrames() - - expect(component.lineNodeForScreenRow(0).textContent).toBe('' + invisibles.space + 'a line with tabs' + invisibles.tab + 'and spaces' + invisibles.space + invisibles.eol) - - let leafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) - expect(leafNodes[0].classList.contains('invisible-character')).toBe(true) - expect(leafNodes[leafNodes.length - 1].classList.contains('invisible-character')).toBe(true) - }) - - it('displays newlines as their own token outside of the other tokens\' scopeDescriptor', function () { - editor.setText('let\n') - runAnimationFrames() - expect(component.lineNodeForScreenRow(0).innerHTML).toBe('let' + invisibles.eol + '') - }) - - it('displays trailing carriage returns using a visible, non-empty value', function () { - editor.setText('a line that ends with a carriage return\r\n') - runAnimationFrames() - expect(component.lineNodeForScreenRow(0).textContent).toBe('a line that ends with a carriage return' + invisibles.cr + invisibles.eol) - }) - - it('renders invisible line-ending characters on empty lines', function () { - expect(component.lineNodeForScreenRow(10).textContent).toBe(invisibles.eol) - }) - - it('renders a placeholder space on empty lines when the line-ending character is an empty string', function () { - editor.update({invisibles: {eol: ''}}) - runAnimationFrames() - expect(component.lineNodeForScreenRow(10).textContent).toBe(' ') - }) - - it('renders an placeholder space on empty lines when the line-ending character is false', function () { - editor.update({invisibles: {eol: false}}) - runAnimationFrames() - expect(component.lineNodeForScreenRow(10).textContent).toBe(' ') - }) - - it('interleaves invisible line-ending characters with indent guides on empty lines', function () { - editor.update({showIndentGuide: true}) - - runAnimationFrames() - - editor.setTabLength(2) - editor.setTextInBufferRange([[10, 0], [11, 0]], '\r\n', { - normalizeLineEndings: false - }) - runAnimationFrames() - expect(component.lineNodeForScreenRow(10).innerHTML).toBe('CE') - - editor.setTabLength(3) - runAnimationFrames() - expect(component.lineNodeForScreenRow(10).innerHTML).toBe('CE') - - editor.setTabLength(1) - runAnimationFrames() - expect(component.lineNodeForScreenRow(10).innerHTML).toBe('CE') - - editor.setTextInBufferRange([[9, 0], [9, Infinity]], ' ') - editor.setTextInBufferRange([[11, 0], [11, Infinity]], ' ') - runAnimationFrames() - expect(component.lineNodeForScreenRow(10).innerHTML).toBe('CE') - }) - - describe('when soft wrapping is enabled', function () { - beforeEach(function () { - editor.setText('a line that wraps \n') - editor.setSoftWrapped(true) - runAnimationFrames() - - componentNode.style.width = 17 * charWidth + wrapperNode.getVerticalScrollbarWidth() + 'px' - component.measureDimensions() - runAnimationFrames() - }) - - it('does not show end of line invisibles at the end of wrapped lines', function () { - expect(component.lineNodeForScreenRow(0).textContent).toBe('a line ') - expect(component.lineNodeForScreenRow(1).textContent).toBe('that wraps' + invisibles.space + invisibles.eol) - }) - }) - }) - - describe('when indent guides are enabled', function () { - beforeEach(function () { - editor.update({showIndentGuide: true}) - runAnimationFrames() - }) - - it('adds an "indent-guide" class to spans comprising the leading whitespace', function () { - let 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) - - let line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2)) - expect(line2LeafNodes[0].textContent).toBe(' ') - expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe(true) - expect(line2LeafNodes[1].textContent).toBe(' ') - expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe(true) - expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe(false) - }) - - it('renders leading whitespace spans with the "indent-guide" class for empty lines', function () { - editor.getBuffer().insert([1, Infinity], '\n') - runAnimationFrames() - - let line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2)) - expect(line2LeafNodes.length).toBe(2) - expect(line2LeafNodes[0].textContent).toBe(' ') - expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe(true) - expect(line2LeafNodes[1].textContent).toBe(' ') - expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe(true) - }) - - it('renders indent guides correctly on lines containing only whitespace', function () { - editor.getBuffer().insert([1, Infinity], '\n ') - runAnimationFrames() - - let line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2)) - expect(line2LeafNodes.length).toBe(3) - expect(line2LeafNodes[0].textContent).toBe(' ') - expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe(true) - expect(line2LeafNodes[1].textContent).toBe(' ') - expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe(true) - expect(line2LeafNodes[2].textContent).toBe(' ') - expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe(true) - }) - - it('renders indent guides correctly on lines containing only whitespace when invisibles are enabled', function () { - editor.update({ - showInvisibles: true, - invisibles: { - space: '-', - eol: 'x' - } - }) - editor.getBuffer().insert([1, Infinity], '\n ') - - runAnimationFrames() - - let line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2)) - expect(line2LeafNodes.length).toBe(4) - expect(line2LeafNodes[0].textContent).toBe('--') - expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe(true) - expect(line2LeafNodes[1].textContent).toBe('--') - expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe(true) - expect(line2LeafNodes[2].textContent).toBe('--') - expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe(true) - expect(line2LeafNodes[3].textContent).toBe('x') - }) - - it('does not render indent guides in trailing whitespace for lines containing non whitespace characters', function () { - editor.getBuffer().setText(' hi ') - - runAnimationFrames() - - let line0LeafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) - expect(line0LeafNodes[0].textContent).toBe(' ') - expect(line0LeafNodes[0].classList.contains('indent-guide')).toBe(true) - expect(line0LeafNodes[1].textContent).toBe(' ') - expect(line0LeafNodes[1].classList.contains('indent-guide')).toBe(false) - }) - - it('updates the indent guides on empty lines preceding an indentation change', function () { - editor.getBuffer().insert([12, 0], '\n') - runAnimationFrames() - - editor.getBuffer().insert([13, 0], ' ') - runAnimationFrames() - - let line12LeafNodes = getLeafNodes(component.lineNodeForScreenRow(12)) - expect(line12LeafNodes[0].textContent).toBe(' ') - expect(line12LeafNodes[0].classList.contains('indent-guide')).toBe(true) - expect(line12LeafNodes[1].textContent).toBe(' ') - expect(line12LeafNodes[1].classList.contains('indent-guide')).toBe(true) - }) - - it('updates the indent guides on empty lines following an indentation change', function () { - editor.getBuffer().insert([12, 2], '\n') - - runAnimationFrames() - - editor.getBuffer().insert([12, 0], ' ') - runAnimationFrames() - - let line13LeafNodes = getLeafNodes(component.lineNodeForScreenRow(13)) - expect(line13LeafNodes[0].textContent).toBe(' ') - expect(line13LeafNodes[0].classList.contains('indent-guide')).toBe(true) - expect(line13LeafNodes[1].textContent).toBe(' ') - expect(line13LeafNodes[1].classList.contains('indent-guide')).toBe(true) - }) - }) - - describe('when indent guides are disabled', function () { - beforeEach(function () { - expect(atom.config.get('editor.showIndentGuide')).toBe(false) - }) - - it('does not render indent guides on lines containing only whitespace', function () { - editor.getBuffer().insert([1, Infinity], '\n ') - - runAnimationFrames() - - let line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2)) - expect(line2LeafNodes.length).toBe(1) - expect(line2LeafNodes[0].textContent).toBe(' ') - expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe(false) - }) - }) - - describe('when the buffer contains null bytes', function () { - it('excludes the null byte from character measurement', function () { - editor.setText('a\0b') - runAnimationFrames() - expect(wrapperNode.pixelPositionForScreenPosition([0, Infinity]).left).toEqual(2 * charWidth) - }) - }) - - describe('when there is a fold', function () { - it('renders a fold marker on the folded line', function () { - let foldedLineNode = component.lineNodeForScreenRow(4) - expect(foldedLineNode.querySelector('.fold-marker')).toBeFalsy() - editor.foldBufferRow(4) - - runAnimationFrames() - - foldedLineNode = component.lineNodeForScreenRow(4) - expect(foldedLineNode.querySelector('.fold-marker')).toBeTruthy() - editor.unfoldBufferRow(4) - - runAnimationFrames() - - foldedLineNode = component.lineNodeForScreenRow(4) - expect(foldedLineNode.querySelector('.fold-marker')).toBeFalsy() - }) - }) - }) - - describe('gutter rendering', function () { - function expectTileContainsRow (tileNode, screenRow, {top, text}) { - let lineNode = tileNode.querySelector('[data-screen-row="' + screenRow + '"]') - expect(lineNode.offsetTop).toBe(top) - expect(lineNode.textContent).toBe(text) - } - - it('renders higher tiles in front of lower ones', function () { - wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames(true) - - let tilesNodes = component.tileNodesForLineNumbers() - expect(tilesNodes[0].style.zIndex).toBe('2') - expect(tilesNodes[1].style.zIndex).toBe('1') - expect(tilesNodes[2].style.zIndex).toBe('0') - verticalScrollbarNode.scrollTop = 1 * lineHeightInPixels - verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - runAnimationFrames(true) - - tilesNodes = component.tileNodesForLineNumbers() - expect(tilesNodes[0].style.zIndex).toBe('3') - expect(tilesNodes[1].style.zIndex).toBe('2') - expect(tilesNodes[2].style.zIndex).toBe('1') - expect(tilesNodes[3].style.zIndex).toBe('0') - }) - - it('gives the line numbers container the same height as the wrapper node', function () { - let linesNode = componentNode.querySelector('.line-numbers') - wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - - runAnimationFrames() - - expect(linesNode.getBoundingClientRect().height).toBe(6.5 * lineHeightInPixels) - wrapperNode.style.height = 3.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - - runAnimationFrames() - - expect(linesNode.getBoundingClientRect().height).toBe(3.5 * lineHeightInPixels) - }) - - it('renders the currently-visible line numbers in a tiled fashion', function () { - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - let tilesNodes = component.tileNodesForLineNumbers() - expect(tilesNodes.length).toBe(3) - - expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, 0px, 0px)') - expect(tilesNodes[0].querySelectorAll('.line-number').length).toBe(3) - expectTileContainsRow(tilesNodes[0], 0, { - top: lineHeightInPixels * 0, - text: '' + NBSP + '1' - }) - expectTileContainsRow(tilesNodes[0], 1, { - top: lineHeightInPixels * 1, - text: '' + NBSP + '2' - }) - expectTileContainsRow(tilesNodes[0], 2, { - top: lineHeightInPixels * 2, - text: '' + NBSP + '3' - }) - - expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels) + 'px, 0px)') - expect(tilesNodes[1].querySelectorAll('.line-number').length).toBe(3) - expectTileContainsRow(tilesNodes[1], 3, { - top: lineHeightInPixels * 0, - text: '' + NBSP + '4' - }) - expectTileContainsRow(tilesNodes[1], 4, { - top: lineHeightInPixels * 1, - text: '' + NBSP + '5' - }) - expectTileContainsRow(tilesNodes[1], 5, { - top: lineHeightInPixels * 2, - text: '' + NBSP + '6' - }) - - expect(tilesNodes[2].style['-webkit-transform']).toBe('translate3d(0px, ' + (2 * tileHeightInPixels) + 'px, 0px)') - expect(tilesNodes[2].querySelectorAll('.line-number').length).toBe(3) - expectTileContainsRow(tilesNodes[2], 6, { - top: lineHeightInPixels * 0, - text: '' + NBSP + '7' - }) - expectTileContainsRow(tilesNodes[2], 7, { - top: lineHeightInPixels * 1, - text: '' + NBSP + '8' - }) - expectTileContainsRow(tilesNodes[2], 8, { - top: lineHeightInPixels * 2, - text: '' + NBSP + '9' - }) - verticalScrollbarNode.scrollTop = TILE_SIZE * lineHeightInPixels + 5 - verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - runAnimationFrames(true) - - tilesNodes = component.tileNodesForLineNumbers() - expect(component.lineNumberNodeForScreenRow(2)).toBeUndefined() - expect(tilesNodes.length).toBe(3) - - expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, ' + (0 * tileHeightInPixels - 5) + 'px, 0px)') - expect(tilesNodes[0].querySelectorAll('.line-number').length).toBe(TILE_SIZE) - expectTileContainsRow(tilesNodes[0], 3, { - top: lineHeightInPixels * 0, - text: '' + NBSP + '4' - }) - expectTileContainsRow(tilesNodes[0], 4, { - top: lineHeightInPixels * 1, - text: '' + NBSP + '5' - }) - expectTileContainsRow(tilesNodes[0], 5, { - top: lineHeightInPixels * 2, - text: '' + NBSP + '6' - }) - - expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels - 5) + 'px, 0px)') - expect(tilesNodes[1].querySelectorAll('.line-number').length).toBe(TILE_SIZE) - expectTileContainsRow(tilesNodes[1], 6, { - top: 0 * lineHeightInPixels, - text: '' + NBSP + '7' - }) - expectTileContainsRow(tilesNodes[1], 7, { - top: 1 * lineHeightInPixels, - text: '' + NBSP + '8' - }) - expectTileContainsRow(tilesNodes[1], 8, { - top: 2 * lineHeightInPixels, - text: '' + NBSP + '9' - }) - - expect(tilesNodes[2].style['-webkit-transform']).toBe('translate3d(0px, ' + (2 * tileHeightInPixels - 5) + 'px, 0px)') - expect(tilesNodes[2].querySelectorAll('.line-number').length).toBe(TILE_SIZE) - expectTileContainsRow(tilesNodes[2], 9, { - top: 0 * lineHeightInPixels, - text: '10' - }) - expectTileContainsRow(tilesNodes[2], 10, { - top: 1 * lineHeightInPixels, - text: '11' - }) - expectTileContainsRow(tilesNodes[2], 11, { - top: 2 * lineHeightInPixels, - text: '12' - }) - }) - - it('updates the translation of subsequent line numbers when lines are inserted or removed', function () { - editor.getBuffer().insert([0, 0], '\n\n') - runAnimationFrames() - - let lineNumberNodes = componentNode.querySelectorAll('.line-number') - expect(component.lineNumberNodeForScreenRow(0).offsetTop).toBe(0 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(1).offsetTop).toBe(1 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(2).offsetTop).toBe(2 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(3).offsetTop).toBe(0 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(4).offsetTop).toBe(1 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(5).offsetTop).toBe(2 * lineHeightInPixels) - editor.getBuffer().insert([0, 0], '\n\n') - - runAnimationFrames() - - expect(component.lineNumberNodeForScreenRow(0).offsetTop).toBe(0 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(1).offsetTop).toBe(1 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(2).offsetTop).toBe(2 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(3).offsetTop).toBe(0 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(4).offsetTop).toBe(1 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(5).offsetTop).toBe(2 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(6).offsetTop).toBe(0 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(7).offsetTop).toBe(1 * lineHeightInPixels) - expect(component.lineNumberNodeForScreenRow(8).offsetTop).toBe(2 * lineHeightInPixels) - }) - - it('renders • characters for soft-wrapped lines', function () { - editor.setSoftWrapped(true) - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 30 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - - runAnimationFrames() - - expect(componentNode.querySelectorAll('.line-number').length).toBe(9 + 1) - 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 + '•') - expect(component.lineNumberNodeForScreenRow(6).textContent).toBe('' + NBSP + '4') - expect(component.lineNumberNodeForScreenRow(7).textContent).toBe('' + NBSP + '•') - expect(component.lineNumberNodeForScreenRow(8).textContent).toBe('' + NBSP + '•') - }) - - it('pads line numbers to be right-justified based on the maximum number of line number digits', function () { - const input = []; - for (let i = 1; i <= 100; ++i) { - input.push(i); - } - editor.getBuffer().setText(input.join('\n')) - runAnimationFrames() - - for (let screenRow = 0; screenRow <= 8; ++screenRow) { - expect(component.lineNumberNodeForScreenRow(screenRow).textContent).toBe('' + NBSP + NBSP + (screenRow + 1)) - } - expect(component.lineNumberNodeForScreenRow(99).textContent).toBe('100') - let gutterNode = componentNode.querySelector('.gutter') - let initialGutterWidth = gutterNode.offsetWidth - editor.getBuffer().delete([[1, 0], [2, 0]]) - - runAnimationFrames() - - for (let screenRow = 0; screenRow <= 8; ++screenRow) { - expect(component.lineNumberNodeForScreenRow(screenRow).textContent).toBe('' + NBSP + (screenRow + 1)) - } - expect(gutterNode.offsetWidth).toBeLessThan(initialGutterWidth) - editor.getBuffer().insert([0, 0], '\n\n') - - runAnimationFrames() - - for (let screenRow = 0; screenRow <= 8; ++screenRow) { - expect(component.lineNumberNodeForScreenRow(screenRow).textContent).toBe('' + NBSP + NBSP + (screenRow + 1)) - } - expect(component.lineNumberNodeForScreenRow(99).textContent).toBe('100') - expect(gutterNode.offsetWidth).toBe(initialGutterWidth) - }) - - it('renders the .line-numbers div at the full height of the editor even if it\'s taller than its content', function () { - wrapperNode.style.height = componentNode.offsetHeight + 100 + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - expect(componentNode.querySelector('.line-numbers').offsetHeight).toBe(componentNode.offsetHeight) - }) - - it('applies the background color of the gutter or the editor to the line numbers to improve GPU performance', function () { - let gutterNode = componentNode.querySelector('.gutter') - let lineNumbersNode = gutterNode.querySelector('.line-numbers') - let backgroundColor = getComputedStyle(wrapperNode).backgroundColor - expect(getComputedStyle(lineNumbersNode).backgroundColor).toBe(backgroundColor) - for (let tileNode of component.tileNodesForLineNumbers()) { - expect(getComputedStyle(tileNode).backgroundColor).toBe(backgroundColor) - } - - gutterNode.style.backgroundColor = 'rgb(255, 0, 0)' - runAnimationFrames() - - expect(getComputedStyle(lineNumbersNode).backgroundColor).toBe('rgb(255, 0, 0)') - for (let tileNode of component.tileNodesForLineNumbers()) { - expect(getComputedStyle(tileNode).backgroundColor).toBe('rgb(255, 0, 0)') - } - }) - - it('hides or shows the gutter based on the "::isLineNumberGutterVisible" property on the model and the global "editor.showLineNumbers" config setting', function () { - expect(component.gutterContainerComponent.getLineNumberGutterComponent() != null).toBe(true) - editor.setLineNumberGutterVisible(false) - runAnimationFrames() - - expect(componentNode.querySelector('.gutter').style.display).toBe('none') - editor.update({showLineNumbers: false}) - runAnimationFrames() - - expect(componentNode.querySelector('.gutter').style.display).toBe('none') - editor.setLineNumberGutterVisible(true) - runAnimationFrames() - - expect(componentNode.querySelector('.gutter').style.display).toBe('none') - editor.update({showLineNumbers: true}) - runAnimationFrames() - - expect(componentNode.querySelector('.gutter').style.display).toBe('') - expect(component.lineNumberNodeForScreenRow(3) != null).toBe(true) - }) - - it('keeps rebuilding line numbers when continuous reflow is on', function () { - wrapperNode.setContinuousReflow(true) - let oldLineNode = componentNode.querySelectorAll('.line-number')[1] - - while (true) { - runAnimationFrames() - if (componentNode.querySelectorAll('.line-number')[1] !== oldLineNode) break - } - }) - - describe('fold decorations', function () { - describe('rendering fold decorations', function () { - it('adds the foldable class to line numbers when the line is foldable', function () { - expect(lineNumberHasClass(0, 'foldable')).toBe(true) - expect(lineNumberHasClass(1, 'foldable')).toBe(true) - expect(lineNumberHasClass(2, 'foldable')).toBe(false) - expect(lineNumberHasClass(3, 'foldable')).toBe(false) - expect(lineNumberHasClass(4, 'foldable')).toBe(true) - expect(lineNumberHasClass(5, 'foldable')).toBe(false) - }) - - it('updates the foldable class on the correct line numbers when the foldable positions change', function () { - editor.getBuffer().insert([0, 0], '\n') - runAnimationFrames() - - expect(lineNumberHasClass(0, 'foldable')).toBe(false) - expect(lineNumberHasClass(1, 'foldable')).toBe(true) - expect(lineNumberHasClass(2, 'foldable')).toBe(true) - expect(lineNumberHasClass(3, 'foldable')).toBe(false) - expect(lineNumberHasClass(4, 'foldable')).toBe(false) - expect(lineNumberHasClass(5, 'foldable')).toBe(true) - expect(lineNumberHasClass(6, 'foldable')).toBe(false) - }) - - it('updates the foldable class on a line number that becomes foldable', function () { - expect(lineNumberHasClass(11, 'foldable')).toBe(false) - editor.getBuffer().insert([11, 44], '\n fold me') - runAnimationFrames() - expect(lineNumberHasClass(11, 'foldable')).toBe(true) - editor.undo() - runAnimationFrames() - expect(lineNumberHasClass(11, 'foldable')).toBe(false) - }) - - it('adds, updates and removes the folded class on the correct line number componentNodes', function () { - editor.foldBufferRow(4) - runAnimationFrames() - - expect(lineNumberHasClass(4, 'folded')).toBe(true) - - editor.getBuffer().insert([0, 0], '\n') - runAnimationFrames() - - expect(lineNumberHasClass(4, 'folded')).toBe(false) - expect(lineNumberHasClass(5, 'folded')).toBe(true) - - editor.unfoldBufferRow(5) - runAnimationFrames() - - expect(lineNumberHasClass(5, 'folded')).toBe(false) - }) - - describe('when soft wrapping is enabled', function () { - beforeEach(function () { - editor.setSoftWrapped(true) - runAnimationFrames() - componentNode.style.width = 20 * charWidth + wrapperNode.getVerticalScrollbarWidth() + 'px' - component.measureDimensions() - runAnimationFrames() - }) - - it('does not add the foldable class for soft-wrapped lines', function () { - expect(lineNumberHasClass(0, 'foldable')).toBe(true) - expect(lineNumberHasClass(1, 'foldable')).toBe(false) - }) - - it('does not add the folded class for soft-wrapped lines that contain a fold', function () { - editor.foldBufferRange([[3, 19], [3, 21]]) - runAnimationFrames() - - expect(lineNumberHasClass(11, 'folded')).toBe(true) - expect(lineNumberHasClass(12, 'folded')).toBe(false) - }) - }) - }) - - describe('mouse interactions with fold indicators', function () { - let gutterNode - - function buildClickEvent (target) { - return buildMouseEvent('click', { - target: target - }) - } - - beforeEach(function () { - gutterNode = componentNode.querySelector('.gutter') - }) - - describe('when the component is destroyed', function () { - it('stops listening for folding events', function () { - let lineNumber, target - component.destroy() - lineNumber = component.lineNumberNodeForScreenRow(1) - target = lineNumber.querySelector('.icon-right') - target.dispatchEvent(buildClickEvent(target)) - }) - }) - - it('folds and unfolds the block represented by the fold indicator when clicked', function () { - expect(lineNumberHasClass(1, 'folded')).toBe(false) - - let lineNumber = component.lineNumberNodeForScreenRow(1) - let target = lineNumber.querySelector('.icon-right') - - target.dispatchEvent(buildClickEvent(target)) - - runAnimationFrames() - - expect(lineNumberHasClass(1, 'folded')).toBe(true) - lineNumber = component.lineNumberNodeForScreenRow(1) - target = lineNumber.querySelector('.icon-right') - target.dispatchEvent(buildClickEvent(target)) - - runAnimationFrames() - - expect(lineNumberHasClass(1, 'folded')).toBe(false) - }) - - it('unfolds all the free-form folds intersecting the buffer row when clicked', function () { - expect(lineNumberHasClass(3, 'foldable')).toBe(false) - - editor.foldBufferRange([[3, 4], [5, 4]]) - editor.foldBufferRange([[5, 5], [8, 10]]) - runAnimationFrames() - expect(lineNumberHasClass(3, 'folded')).toBe(true) - expect(lineNumberHasClass(5, 'folded')).toBe(false) - - let lineNumber = component.lineNumberNodeForScreenRow(3) - let target = lineNumber.querySelector('.icon-right') - target.dispatchEvent(buildClickEvent(target)) - runAnimationFrames() - expect(lineNumberHasClass(3, 'folded')).toBe(false) - expect(lineNumberHasClass(5, 'folded')).toBe(true) - - editor.setSoftWrapped(true) - componentNode.style.width = 20 * charWidth + wrapperNode.getVerticalScrollbarWidth() + 'px' - component.measureDimensions() - runAnimationFrames() - editor.foldBufferRange([[3, 19], [3, 21]]) // fold starting on a soft-wrapped portion of the line - runAnimationFrames() - expect(lineNumberHasClass(11, 'folded')).toBe(true) - - lineNumber = component.lineNumberNodeForScreenRow(11) - target = lineNumber.querySelector('.icon-right') - target.dispatchEvent(buildClickEvent(target)) - runAnimationFrames() - expect(lineNumberHasClass(11, 'folded')).toBe(false) - }) - - it('does not fold when the line number componentNode is clicked', function () { - let lineNumber = component.lineNumberNodeForScreenRow(1) - lineNumber.dispatchEvent(buildClickEvent(lineNumber)) - waits(100) - runs(function () { - expect(lineNumberHasClass(1, 'folded')).toBe(false) - }) - }) - }) - }) - }) - - describe('cursor rendering', function () { - it('renders the currently visible cursors', function () { - let cursor1 = editor.getLastCursor() - cursor1.setScreenPosition([0, 5], { - autoscroll: false - }) - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 20 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - let cursorNodes = componentNode.querySelectorAll('.cursor') - expect(cursorNodes.length).toBe(1) - expect(cursorNodes[0].offsetHeight).toBe(lineHeightInPixels) - expect(cursorNodes[0].offsetWidth).toBeCloseTo(charWidth, 0) - expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(5 * charWidth)) + 'px, ' + (0 * lineHeightInPixels) + 'px)') - let cursor2 = editor.addCursorAtScreenPosition([8, 11], { - autoscroll: false - }) - let cursor3 = editor.addCursorAtScreenPosition([4, 10], { - autoscroll: false - }) - runAnimationFrames() - - cursorNodes = componentNode.querySelectorAll('.cursor') - expect(cursorNodes.length).toBe(2) - expect(cursorNodes[0].offsetTop).toBe(0) - expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(5 * charWidth)) + 'px, ' + (0 * lineHeightInPixels) + 'px)') - expect(cursorNodes[1].style['-webkit-transform']).toBe('translate(' + (Math.round(10 * charWidth)) + 'px, ' + (4 * lineHeightInPixels) + 'px)') - verticalScrollbarNode.scrollTop = 4.5 * lineHeightInPixels - verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - runAnimationFrames(true) - - horizontalScrollbarNode.scrollLeft = 3.5 * charWidth - horizontalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - runAnimationFrames(true) - - cursorNodes = componentNode.querySelectorAll('.cursor') - expect(cursorNodes.length).toBe(2) - expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(10 * charWidth - horizontalScrollbarNode.scrollLeft)) + 'px, ' + (4 * lineHeightInPixels - verticalScrollbarNode.scrollTop) + 'px)') - expect(cursorNodes[1].style['-webkit-transform']).toBe('translate(' + (Math.round(11 * charWidth - horizontalScrollbarNode.scrollLeft)) + 'px, ' + (8 * lineHeightInPixels - verticalScrollbarNode.scrollTop) + 'px)') - editor.onDidChangeCursorPosition(cursorMovedListener = jasmine.createSpy('cursorMovedListener')) - cursor3.setScreenPosition([4, 11], { - autoscroll: false - }) - runAnimationFrames() - - expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(11 * charWidth - horizontalScrollbarNode.scrollLeft)) + 'px, ' + (4 * lineHeightInPixels - verticalScrollbarNode.scrollTop) + 'px)') - expect(cursorMovedListener).toHaveBeenCalled() - cursor3.destroy() - runAnimationFrames() - - cursorNodes = componentNode.querySelectorAll('.cursor') - expect(cursorNodes.length).toBe(1) - expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(11 * charWidth - horizontalScrollbarNode.scrollLeft)) + 'px, ' + (8 * lineHeightInPixels - verticalScrollbarNode.scrollTop) + 'px)') - }) - - it('accounts for character widths when positioning cursors', function () { - component.setFontFamily('sans-serif') - editor.setCursorScreenPosition([0, 16]) - runAnimationFrames() - - let cursor = componentNode.querySelector('.cursor') - let cursorRect = cursor.getBoundingClientRect() - let cursorLocationTextNode = component.lineNodeForScreenRow(0).querySelector('.syntax--storage.syntax--type.syntax--function.syntax--js').firstChild - let range = document.createRange() - range.setStart(cursorLocationTextNode, 0) - range.setEnd(cursorLocationTextNode, 1) - let rangeRect = range.getBoundingClientRect() - expect(cursorRect.left).toBeCloseTo(rangeRect.left, 0) - expect(cursorRect.width).toBeCloseTo(rangeRect.width, 0) - }) - - it('accounts for the width of paired characters when positioning cursors', function () { - component.setFontFamily('sans-serif') - editor.setText('he\u0301y') - editor.setCursorBufferPosition([0, 3]) - runAnimationFrames() - - let cursor = componentNode.querySelector('.cursor') - let cursorRect = cursor.getBoundingClientRect() - let cursorLocationTextNode = component.lineNodeForScreenRow(0).querySelector('.syntax--source.syntax--js').childNodes[0] - let range = document.createRange(cursorLocationTextNode) - range.setStart(cursorLocationTextNode, 3) - range.setEnd(cursorLocationTextNode, 4) - let rangeRect = range.getBoundingClientRect() - expect(cursorRect.left).toBeCloseTo(rangeRect.left, 0) - expect(cursorRect.width).toBeCloseTo(rangeRect.width, 0) - }) - - it('positions cursors after the fold-marker when a fold ends the line', function () { - editor.foldBufferRow(0) - runAnimationFrames() - editor.setCursorScreenPosition([0, 30]) - runAnimationFrames() - - let cursorRect = componentNode.querySelector('.cursor').getBoundingClientRect() - let foldMarkerRect = componentNode.querySelector('.fold-marker').getBoundingClientRect() - expect(cursorRect.left).toBeCloseTo(foldMarkerRect.right, 0) - }) - - it('positions cursors correctly after character widths are changed via a stylesheet change', function () { - component.setFontFamily('sans-serif') - editor.setCursorScreenPosition([0, 16]) - runAnimationFrames(true) - - atom.styles.addStyleSheet('.syntax--function.syntax--js {\n font-weight: bold;\n}', { - context: 'atom-text-editor' - }) - runAnimationFrames(true) - - let cursor = componentNode.querySelector('.cursor') - let cursorRect = cursor.getBoundingClientRect() - let cursorLocationTextNode = component.lineNodeForScreenRow(0).querySelector('.syntax--storage.syntax--type.syntax--function.syntax--js').firstChild - let range = document.createRange() - range.setStart(cursorLocationTextNode, 0) - range.setEnd(cursorLocationTextNode, 1) - let rangeRect = range.getBoundingClientRect() - expect(cursorRect.left).toBeCloseTo(rangeRect.left, 0) - expect(cursorRect.width).toBeCloseTo(rangeRect.width, 0) - atom.themes.removeStylesheet('test') - }) - - it('sets the cursor to the default character width at the end of a line', function () { - editor.setCursorScreenPosition([0, Infinity]) - runAnimationFrames() - let cursorNode = componentNode.querySelector('.cursor') - expect(cursorNode.offsetWidth).toBeCloseTo(charWidth, 0) - }) - - it('gives the cursor a non-zero width even if it\'s inside atomic tokens', function () { - editor.setCursorScreenPosition([1, 0]) - runAnimationFrames() - let cursorNode = componentNode.querySelector('.cursor') - expect(cursorNode.offsetWidth).toBeCloseTo(charWidth, 0) - }) - - it('blinks cursors when they are not moving', async function () { - let cursorsNode = componentNode.querySelector('.cursors') - wrapperNode.focus() - runAnimationFrames() - expect(cursorsNode.classList.contains('blink-off')).toBe(false) - advanceClock(component.cursorBlinkPeriod / 2) - runAnimationFrames() - expect(cursorsNode.classList.contains('blink-off')).toBe(true) - advanceClock(component.cursorBlinkPeriod / 2) - runAnimationFrames() - expect(cursorsNode.classList.contains('blink-off')).toBe(false) - editor.moveRight() - runAnimationFrames() - expect(cursorsNode.classList.contains('blink-off')).toBe(false) - advanceClock(component.cursorBlinkResumeDelay) - runAnimationFrames(true) - expect(cursorsNode.classList.contains('blink-off')).toBe(false) - advanceClock(component.cursorBlinkPeriod / 2) - runAnimationFrames() - expect(cursorsNode.classList.contains('blink-off')).toBe(true) - }) - - it('renders cursors that are associated with empty selections', function () { - editor.update({showCursorOnSelection: true}) - editor.setSelectedScreenRange([[0, 4], [4, 6]]) - editor.addCursorAtScreenPosition([6, 8]) - runAnimationFrames() - let cursorNodes = componentNode.querySelectorAll('.cursor') - expect(cursorNodes.length).toBe(2) - expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(6 * charWidth)) + 'px, ' + (4 * lineHeightInPixels) + 'px)') - expect(cursorNodes[1].style['-webkit-transform']).toBe('translate(' + (Math.round(8 * charWidth)) + 'px, ' + (6 * lineHeightInPixels) + 'px)') - }) - - it('does not render cursors that are associated with non-empty selections when showCursorOnSelection is false', function () { - editor.update({showCursorOnSelection: false}) - editor.setSelectedScreenRange([[0, 4], [4, 6]]) - editor.addCursorAtScreenPosition([6, 8]) - runAnimationFrames() - let cursorNodes = componentNode.querySelectorAll('.cursor') - expect(cursorNodes.length).toBe(1) - expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(8 * charWidth)) + 'px, ' + (6 * lineHeightInPixels) + 'px)') - }) - - it('updates cursor positions when the line height changes', function () { - editor.setCursorBufferPosition([1, 10]) - component.setLineHeight(2) - runAnimationFrames() - let cursorNode = componentNode.querySelector('.cursor') - expect(cursorNode.style['-webkit-transform']).toBe('translate(' + (Math.round(10 * editor.getDefaultCharWidth())) + 'px, ' + (editor.getLineHeightInPixels()) + 'px)') - }) - - it('updates cursor positions when the font size changes', function () { - editor.setCursorBufferPosition([1, 10]) - component.setFontSize(10) - runAnimationFrames() - let cursorNode = componentNode.querySelector('.cursor') - expect(cursorNode.style['-webkit-transform']).toBe('translate(' + (Math.round(10 * editor.getDefaultCharWidth())) + 'px, ' + (editor.getLineHeightInPixels()) + 'px)') - }) - - it('updates cursor positions when the font family changes', function () { - editor.setCursorBufferPosition([1, 10]) - component.setFontFamily('sans-serif') - runAnimationFrames() - let cursorNode = componentNode.querySelector('.cursor') - let left = wrapperNode.pixelPositionForScreenPosition([1, 10]).left - expect(cursorNode.style['-webkit-transform']).toBe('translate(' + (Math.round(left)) + 'px, ' + (editor.getLineHeightInPixels()) + 'px)') - }) - }) - - describe('selection rendering', function () { - let scrollViewClientLeft, scrollViewNode - - beforeEach(function () { - scrollViewNode = componentNode.querySelector('.scroll-view') - scrollViewClientLeft = componentNode.querySelector('.scroll-view').getBoundingClientRect().left - }) - - it('renders 1 region for 1-line selections', function () { - editor.setSelectedScreenRange([[1, 6], [1, 10]]) - runAnimationFrames() - - let regions = componentNode.querySelectorAll('.selection .region') - expect(regions.length).toBe(1) - - let regionRect = regions[0].getBoundingClientRect() - expect(regionRect.top).toBe(1 * lineHeightInPixels) - expect(regionRect.height).toBe(1 * lineHeightInPixels) - expect(regionRect.left).toBeCloseTo(scrollViewClientLeft + 6 * charWidth, 0) - expect(regionRect.width).toBeCloseTo(4 * charWidth, 0) - }) - - it('renders 2 regions for 2-line selections', function () { - editor.setSelectedScreenRange([[1, 6], [2, 10]]) - runAnimationFrames() - - let tileNode = component.tileNodesForLines()[0] - let regions = tileNode.querySelectorAll('.selection .region') - expect(regions.length).toBe(2) - - let region1Rect = regions[0].getBoundingClientRect() - expect(region1Rect.top).toBe(1 * lineHeightInPixels) - expect(region1Rect.height).toBe(1 * lineHeightInPixels) - expect(region1Rect.left).toBeCloseTo(scrollViewClientLeft + 6 * charWidth, 0) - expect(region1Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0) - - let region2Rect = regions[1].getBoundingClientRect() - expect(region2Rect.top).toBe(2 * lineHeightInPixels) - expect(region2Rect.height).toBe(1 * lineHeightInPixels) - expect(region2Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0) - expect(region2Rect.width).toBeCloseTo(10 * charWidth, 0) - }) - - it('renders 3 regions per tile for selections with more than 2 lines', function () { - editor.setSelectedScreenRange([[0, 6], [5, 10]]) - runAnimationFrames() - - let region1Rect, region2Rect, region3Rect, regions, tileNode - tileNode = component.tileNodesForLines()[0] - regions = tileNode.querySelectorAll('.selection .region') - expect(regions.length).toBe(3) - - region1Rect = regions[0].getBoundingClientRect() - expect(region1Rect.top).toBe(0) - expect(region1Rect.height).toBe(1 * lineHeightInPixels) - expect(region1Rect.left).toBeCloseTo(scrollViewClientLeft + 6 * charWidth, 0) - expect(region1Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0) - - region2Rect = regions[1].getBoundingClientRect() - expect(region2Rect.top).toBe(1 * lineHeightInPixels) - expect(region2Rect.height).toBe(1 * lineHeightInPixels) - expect(region2Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0) - expect(region2Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0) - - region3Rect = regions[2].getBoundingClientRect() - expect(region3Rect.top).toBe(2 * lineHeightInPixels) - expect(region3Rect.height).toBe(1 * lineHeightInPixels) - expect(region3Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0) - expect(region3Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0) - - tileNode = component.tileNodesForLines()[1] - regions = tileNode.querySelectorAll('.selection .region') - expect(regions.length).toBe(3) - - region1Rect = regions[0].getBoundingClientRect() - expect(region1Rect.top).toBe(3 * lineHeightInPixels) - expect(region1Rect.height).toBe(1 * lineHeightInPixels) - expect(region1Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0) - expect(region1Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0) - - region2Rect = regions[1].getBoundingClientRect() - expect(region2Rect.top).toBe(4 * lineHeightInPixels) - expect(region2Rect.height).toBe(1 * lineHeightInPixels) - expect(region2Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0) - expect(region2Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0) - - region3Rect = regions[2].getBoundingClientRect() - expect(region3Rect.top).toBe(5 * lineHeightInPixels) - expect(region3Rect.height).toBe(1 * lineHeightInPixels) - expect(region3Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0) - expect(region3Rect.width).toBeCloseTo(10 * charWidth, 0) - }) - - it('does not render empty selections', function () { - editor.addSelectionForBufferRange([[2, 2], [2, 2]]) - runAnimationFrames() - expect(editor.getSelections()[0].isEmpty()).toBe(true) - expect(editor.getSelections()[1].isEmpty()).toBe(true) - expect(componentNode.querySelectorAll('.selection').length).toBe(0) - }) - - it('updates selections when the line height changes', function () { - editor.setSelectedBufferRange([[1, 6], [1, 10]]) - component.setLineHeight(2) - runAnimationFrames() - let selectionNode = componentNode.querySelector('.region') - expect(selectionNode.offsetTop).toBe(editor.getLineHeightInPixels()) - }) - - it('updates selections when the font size changes', function () { - editor.setSelectedBufferRange([[1, 6], [1, 10]]) - component.setFontSize(10) - - runAnimationFrames() - - let selectionNode = componentNode.querySelector('.region') - expect(selectionNode.offsetTop).toBe(editor.getLineHeightInPixels()) - expect(selectionNode.offsetLeft).toBeCloseTo(6 * editor.getDefaultCharWidth(), 0) - }) - - it('updates selections when the font family changes', function () { - editor.setSelectedBufferRange([[1, 6], [1, 10]]) - component.setFontFamily('sans-serif') - - runAnimationFrames() - - let selectionNode = componentNode.querySelector('.region') - expect(selectionNode.offsetTop).toBe(editor.getLineHeightInPixels()) - expect(selectionNode.offsetLeft).toBeCloseTo(wrapperNode.pixelPositionForScreenPosition([1, 6]).left, 0) - }) - - it('will flash the selection when flash:true is passed to editor::setSelectedBufferRange', async function () { - editor.setSelectedBufferRange([[1, 6], [1, 10]], { - flash: true - }) - runAnimationFrames() - - let selectionNode = componentNode.querySelector('.selection') - expect(selectionNode.classList.contains('flash')).toBe(true) - - advanceClock(editor.selectionFlashDuration) - - editor.setSelectedBufferRange([[1, 5], [1, 7]], { - flash: true - }) - runAnimationFrames() - - expect(selectionNode.classList.contains('flash')).toBe(true) - }) - }) - - describe('line decoration rendering', async function () { - let decoration, marker - - beforeEach(async function () { - marker = editor.addMarkerLayer({ - maintainHistory: true - }).markBufferRange([[2, 13], [3, 15]], { - invalidate: 'inside' - }) - decoration = editor.decorateMarker(marker, { - type: ['line-number', 'line'], - 'class': 'a' - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - }) - - it('applies line decoration classes to lines and line numbers', async function () { - expect(lineAndLineNumberHaveClass(2, 'a')).toBe(true) - expect(lineAndLineNumberHaveClass(3, 'a')).toBe(true) - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - let marker2 = editor.markBufferRange([[9, 0], [9, 0]]) - editor.decorateMarker(marker2, { - type: ['line-number', 'line'], - 'class': 'b' - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - verticalScrollbarNode.scrollTop = 4.5 * lineHeightInPixels - verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - runAnimationFrames(true) - - expect(lineAndLineNumberHaveClass(9, 'b')).toBe(true) - - editor.foldBufferRow(5) - runAnimationFrames() - - expect(lineAndLineNumberHaveClass(9, 'b')).toBe(false) - expect(lineAndLineNumberHaveClass(6, 'b')).toBe(true) - }) - - it('only applies decorations to screen rows that are spanned by their marker when lines are soft-wrapped', async function () { - editor.setText('a line that wraps, ok') - editor.setSoftWrapped(true) - componentNode.style.width = 16 * charWidth + 'px' - component.measureDimensions() - - runAnimationFrames() - marker.destroy() - marker = editor.markBufferRange([[0, 0], [0, 2]]) - editor.decorateMarker(marker, { - type: ['line-number', 'line'], - 'class': 'b' - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(lineNumberHasClass(0, 'b')).toBe(true) - expect(lineNumberHasClass(1, 'b')).toBe(false) - marker.setBufferRange([[0, 0], [0, Infinity]]) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(lineNumberHasClass(0, 'b')).toBe(true) - expect(lineNumberHasClass(1, 'b')).toBe(true) - }) - - it('updates decorations when markers move', async function () { - expect(lineAndLineNumberHaveClass(1, 'a')).toBe(false) - expect(lineAndLineNumberHaveClass(2, 'a')).toBe(true) - expect(lineAndLineNumberHaveClass(3, 'a')).toBe(true) - expect(lineAndLineNumberHaveClass(4, 'a')).toBe(false) - - editor.getBuffer().insert([0, 0], '\n') - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(lineAndLineNumberHaveClass(2, 'a')).toBe(false) - expect(lineAndLineNumberHaveClass(3, 'a')).toBe(true) - expect(lineAndLineNumberHaveClass(4, 'a')).toBe(true) - expect(lineAndLineNumberHaveClass(5, 'a')).toBe(false) - - marker.setBufferRange([[4, 4], [6, 4]]) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(lineAndLineNumberHaveClass(2, 'a')).toBe(false) - expect(lineAndLineNumberHaveClass(3, 'a')).toBe(false) - expect(lineAndLineNumberHaveClass(4, 'a')).toBe(true) - expect(lineAndLineNumberHaveClass(5, 'a')).toBe(true) - expect(lineAndLineNumberHaveClass(6, 'a')).toBe(true) - expect(lineAndLineNumberHaveClass(7, 'a')).toBe(false) - }) - - it('remove decoration classes when decorations are removed', async function () { - decoration.destroy() - await decorationsUpdatedPromise(editor) - runAnimationFrames() - expect(lineNumberHasClass(1, 'a')).toBe(false) - expect(lineNumberHasClass(2, 'a')).toBe(false) - expect(lineNumberHasClass(3, 'a')).toBe(false) - expect(lineNumberHasClass(4, 'a')).toBe(false) - }) - - it('removes decorations when their marker is invalidated', async function () { - editor.getBuffer().insert([3, 2], 'n') - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(marker.isValid()).toBe(false) - expect(lineAndLineNumberHaveClass(1, 'a')).toBe(false) - expect(lineAndLineNumberHaveClass(2, 'a')).toBe(false) - expect(lineAndLineNumberHaveClass(3, 'a')).toBe(false) - expect(lineAndLineNumberHaveClass(4, 'a')).toBe(false) - editor.undo() - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(marker.isValid()).toBe(true) - expect(lineAndLineNumberHaveClass(1, 'a')).toBe(false) - expect(lineAndLineNumberHaveClass(2, 'a')).toBe(true) - expect(lineAndLineNumberHaveClass(3, 'a')).toBe(true) - expect(lineAndLineNumberHaveClass(4, 'a')).toBe(false) - }) - - it('removes decorations when their marker is destroyed', async function () { - marker.destroy() - await decorationsUpdatedPromise(editor) - runAnimationFrames() - expect(lineNumberHasClass(1, 'a')).toBe(false) - expect(lineNumberHasClass(2, 'a')).toBe(false) - expect(lineNumberHasClass(3, 'a')).toBe(false) - expect(lineNumberHasClass(4, 'a')).toBe(false) - }) - - describe('when the decoration\'s "onlyHead" property is true', async function () { - it('only applies the decoration\'s class to lines containing the marker\'s head', async function () { - editor.decorateMarker(marker, { - type: ['line-number', 'line'], - 'class': 'only-head', - onlyHead: true - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - expect(lineAndLineNumberHaveClass(1, 'only-head')).toBe(false) - expect(lineAndLineNumberHaveClass(2, 'only-head')).toBe(false) - expect(lineAndLineNumberHaveClass(3, 'only-head')).toBe(true) - expect(lineAndLineNumberHaveClass(4, 'only-head')).toBe(false) - }) - }) - - describe('when the decoration\'s "onlyEmpty" property is true', function () { - it('only applies the decoration when its marker is empty', async function () { - editor.decorateMarker(marker, { - type: ['line-number', 'line'], - 'class': 'only-empty', - onlyEmpty: true - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(lineAndLineNumberHaveClass(2, 'only-empty')).toBe(false) - expect(lineAndLineNumberHaveClass(3, 'only-empty')).toBe(false) - - marker.clearTail() - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(lineAndLineNumberHaveClass(2, 'only-empty')).toBe(false) - expect(lineAndLineNumberHaveClass(3, 'only-empty')).toBe(true) - }) - }) - - describe('when the decoration\'s "onlyNonEmpty" property is true', function () { - it('only applies the decoration when its marker is non-empty', async function () { - editor.decorateMarker(marker, { - type: ['line-number', 'line'], - 'class': 'only-non-empty', - onlyNonEmpty: true - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(lineAndLineNumberHaveClass(2, 'only-non-empty')).toBe(true) - expect(lineAndLineNumberHaveClass(3, 'only-non-empty')).toBe(true) - - marker.clearTail() - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(lineAndLineNumberHaveClass(2, 'only-non-empty')).toBe(false) - expect(lineAndLineNumberHaveClass(3, 'only-non-empty')).toBe(false) - }) - }) - }) - - describe('block decorations rendering', function () { - let markerLayer - - function createBlockDecorationBeforeScreenRow(screenRow, {className}) { - let item = document.createElement("div") - item.className = className || "" - let blockDecoration = editor.decorateMarker( - markerLayer.markScreenPosition([screenRow, 0], {invalidate: "never"}), - {type: "block", item: item, position: "before"} - ) - return [item, blockDecoration] - } - - function createBlockDecorationAfterScreenRow(screenRow, {className}) { - let item = document.createElement("div") - item.className = className || "" - let blockDecoration = editor.decorateMarker( - markerLayer.markScreenPosition([screenRow, 0], {invalidate: "never"}), - {type: "block", item: item, position: "after"} - ) - return [item, blockDecoration] - } - - beforeEach(function () { - markerLayer = editor.addMarkerLayer() - wrapperNode.style.height = 5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - }) - - afterEach(function () { - atom.themes.removeStylesheet('test') - }) - - it("renders visible and yet-to-be-measured block decorations, inserting them between the appropriate lines and refreshing them as needed", async function () { - let [item1, blockDecoration1] = createBlockDecorationBeforeScreenRow(0, {className: "decoration-1"}) - let [item2, blockDecoration2] = createBlockDecorationBeforeScreenRow(2, {className: "decoration-2"}) - let [item3, blockDecoration3] = createBlockDecorationBeforeScreenRow(4, {className: "decoration-3"}) - let [item4, blockDecoration4] = createBlockDecorationBeforeScreenRow(7, {className: "decoration-4"}) - let [item5, blockDecoration5] = createBlockDecorationAfterScreenRow(7, {className: "decoration-5"}) - let [item6, blockDecoration6] = createBlockDecorationAfterScreenRow(12, {className: "decoration-6"}) - - atom.styles.addStyleSheet( - `atom-text-editor .decoration-1 { width: 30px; height: 80px; } - atom-text-editor .decoration-2 { width: 30px; height: 40px; } - atom-text-editor .decoration-3 { width: 30px; height: 100px; } - atom-text-editor .decoration-4 { width: 30px; height: 120px; } - atom-text-editor .decoration-5 { width: 30px; height: 42px; } - atom-text-editor .decoration-6 { width: 30px; height: 22px; }`, - {context: 'atom-text-editor'} - ) - runAnimationFrames() - expect(component.getDomNode().querySelectorAll(".line").length).toBe(7) - expect(verticalScrollbarNode.scrollHeight).toBe(editor.getScreenLineCount() * editor.getLineHeightInPixels() + 80 + 40 + 100 + 120 + 42 + 22) - expect(component.tileNodesForLines()[0].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 80 + 40 + "px") - expect(component.tileNodesForLines()[0].style.webkitTransform).toBe("translate3d(0px, 0px, 0px)") - expect(component.tileNodesForLines()[1].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 100 + "px") - expect(component.tileNodesForLines()[1].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight}px, 0px)`) - expect(component.tileNodesForLines()[2].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 120 + 42 + "px") - expect(component.tileNodesForLines()[2].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight + component.tileNodesForLines()[1].offsetHeight}px, 0px)`) - expect(component.getTopmostDOMNode().querySelector(".decoration-1")).toBe(item1) - expect(component.getTopmostDOMNode().querySelector(".decoration-2")).toBe(item2) - expect(component.getTopmostDOMNode().querySelector(".decoration-3")).toBe(item3) - expect(component.getTopmostDOMNode().querySelector(".decoration-4")).toBeNull() - expect(component.getTopmostDOMNode().querySelector(".decoration-5")).toBeNull() - expect(component.getTopmostDOMNode().querySelector(".decoration-6")).toBeNull() - expect(item1.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 0) - expect(item2.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 2 + 80) - expect(item3.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 4 + 80 + 40) - - editor.setCursorScreenPosition([0, 0]) - editor.insertNewline() - blockDecoration1.destroy() - runAnimationFrames() - expect(component.getDomNode().querySelectorAll(".line").length).toBe(7) - expect(verticalScrollbarNode.scrollHeight).toBe(editor.getScreenLineCount() * editor.getLineHeightInPixels() + 40 + 100 + 120 + 42 + 22) - expect(component.tileNodesForLines()[0].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + "px") - expect(component.tileNodesForLines()[0].style.webkitTransform).toBe("translate3d(0px, 0px, 0px)") - expect(component.tileNodesForLines()[1].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 100 + 40 + "px") - expect(component.tileNodesForLines()[1].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight}px, 0px)`) - expect(component.tileNodesForLines()[2].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 120 + 42 + "px") - expect(component.tileNodesForLines()[2].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight + component.tileNodesForLines()[1].offsetHeight}px, 0px)`) - expect(component.getTopmostDOMNode().querySelector(".decoration-1")).toBeNull() - expect(component.getTopmostDOMNode().querySelector(".decoration-2")).toBe(item2) - expect(component.getTopmostDOMNode().querySelector(".decoration-3")).toBe(item3) - expect(component.getTopmostDOMNode().querySelector(".decoration-4")).toBeNull() - expect(component.getTopmostDOMNode().querySelector(".decoration-5")).toBeNull() - expect(component.getTopmostDOMNode().querySelector(".decoration-6")).toBeNull() - expect(item2.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 3) - expect(item3.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 5 + 40) - - atom.styles.addStyleSheet( - 'atom-text-editor .decoration-2 { height: 60px; }', - {context: 'atom-text-editor'} - ) - - runAnimationFrames() // causes the DOM to update and to retrieve new styles - runAnimationFrames() // applies the changes - expect(component.getDomNode().querySelectorAll(".line").length).toBe(7) - expect(verticalScrollbarNode.scrollHeight).toBe(editor.getScreenLineCount() * editor.getLineHeightInPixels() + 60 + 100 + 120 + 42 + 22) - expect(component.tileNodesForLines()[0].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + "px") - expect(component.tileNodesForLines()[0].style.webkitTransform).toBe("translate3d(0px, 0px, 0px)") - expect(component.tileNodesForLines()[1].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 100 + 60 + "px") - expect(component.tileNodesForLines()[1].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight}px, 0px)`) - expect(component.tileNodesForLines()[2].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 120 + 42 + "px") - expect(component.tileNodesForLines()[2].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight + component.tileNodesForLines()[1].offsetHeight}px, 0px)`) - expect(component.getTopmostDOMNode().querySelector(".decoration-1")).toBeNull() - expect(component.getTopmostDOMNode().querySelector(".decoration-2")).toBe(item2) - expect(component.getTopmostDOMNode().querySelector(".decoration-3")).toBe(item3) - expect(component.getTopmostDOMNode().querySelector(".decoration-4")).toBeNull() - expect(component.getTopmostDOMNode().querySelector(".decoration-5")).toBeNull() - expect(component.getTopmostDOMNode().querySelector(".decoration-6")).toBeNull() - expect(item2.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 3) - expect(item3.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 5 + 60) - - item2.style.height = "20px" - wrapperNode.invalidateBlockDecorationDimensions(blockDecoration2) - runAnimationFrames() // causes the DOM to update and to retrieve new styles - runAnimationFrames() // applies the changes - expect(component.getDomNode().querySelectorAll(".line").length).toBe(9) - expect(verticalScrollbarNode.scrollHeight).toBe(editor.getScreenLineCount() * editor.getLineHeightInPixels() + 20 + 100 + 120 + 42 + 22) - expect(component.tileNodesForLines()[0].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + "px") - expect(component.tileNodesForLines()[0].style.webkitTransform).toBe("translate3d(0px, 0px, 0px)") - expect(component.tileNodesForLines()[1].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 100 + 20 + "px") - expect(component.tileNodesForLines()[1].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight}px, 0px)`) - expect(component.tileNodesForLines()[2].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 120 + 42 + "px") - expect(component.tileNodesForLines()[2].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight + component.tileNodesForLines()[1].offsetHeight}px, 0px)`) - expect(component.getTopmostDOMNode().querySelector(".decoration-1")).toBeNull() - expect(component.getTopmostDOMNode().querySelector(".decoration-2")).toBe(item2) - expect(component.getTopmostDOMNode().querySelector(".decoration-3")).toBe(item3) - expect(component.getTopmostDOMNode().querySelector(".decoration-4")).toBe(item4) - expect(component.getTopmostDOMNode().querySelector(".decoration-5")).toBe(item5) - expect(component.getTopmostDOMNode().querySelector(".decoration-6")).toBeNull() - expect(item2.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 3) - expect(item3.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 5 + 20) - expect(item4.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 8 + 20 + 100) - expect(item5.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 8 + 20 + 100 + 120 + lineHeightInPixels) - - item6.style.height = "33px" - wrapperNode.invalidateBlockDecorationDimensions(blockDecoration6) - runAnimationFrames() // causes the DOM to update and to retrieve new styles - runAnimationFrames() // applies the changes - expect(component.getDomNode().querySelectorAll(".line").length).toBe(9) - expect(verticalScrollbarNode.scrollHeight).toBe(editor.getScreenLineCount() * editor.getLineHeightInPixels() + 20 + 100 + 120 + 42 + 33) - expect(component.tileNodesForLines()[0].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + "px") - expect(component.tileNodesForLines()[0].style.webkitTransform).toBe("translate3d(0px, 0px, 0px)") - expect(component.tileNodesForLines()[1].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 100 + 20 + "px") - expect(component.tileNodesForLines()[1].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight}px, 0px)`) - expect(component.tileNodesForLines()[2].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 120 + 42 + "px") - expect(component.tileNodesForLines()[2].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight + component.tileNodesForLines()[1].offsetHeight}px, 0px)`) - expect(component.getTopmostDOMNode().querySelector(".decoration-1")).toBeNull() - expect(component.getTopmostDOMNode().querySelector(".decoration-2")).toBe(item2) - expect(component.getTopmostDOMNode().querySelector(".decoration-3")).toBe(item3) - expect(component.getTopmostDOMNode().querySelector(".decoration-4")).toBe(item4) - expect(component.getTopmostDOMNode().querySelector(".decoration-5")).toBe(item5) - expect(component.getTopmostDOMNode().querySelector(".decoration-6")).toBeNull() - expect(item2.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 3) - expect(item3.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 5 + 20) - expect(item4.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 8 + 20 + 100) - expect(item5.getBoundingClientRect().top).toBe(editor.getLineHeightInPixels() * 8 + 20 + 100 + 120 + lineHeightInPixels) - }) - - it("correctly sets screen rows on block decoration and ruler nodes, both initially and when decorations move", function () { - let [item, blockDecoration] = createBlockDecorationBeforeScreenRow(0, {className: "decoration-1"}) - atom.styles.addStyleSheet( - 'atom-text-editor .decoration-1 { width: 30px; height: 80px; }', - {context: 'atom-text-editor'} - ) - - runAnimationFrames() - const line0 = component.lineNodeForScreenRow(0) - expect(item.previousSibling.dataset.screenRow).toBe("0") - expect(item.dataset.screenRow).toBe("0") - expect(item.nextSibling.dataset.screenRow).toBe("0") - expect(line0.previousSibling).toBe(item.nextSibling) - - editor.setCursorBufferPosition([0, 0]) - editor.insertNewline() - runAnimationFrames() - const line1 = component.lineNodeForScreenRow(1) - expect(item.previousSibling.dataset.screenRow).toBe("1") - expect(item.dataset.screenRow).toBe("1") - expect(item.nextSibling.dataset.screenRow).toBe("1") - expect(line1.previousSibling).toBe(item.nextSibling) - - editor.setCursorBufferPosition([0, 0]) - editor.insertNewline() - runAnimationFrames() - const line2 = component.lineNodeForScreenRow(2) - expect(item.previousSibling.dataset.screenRow).toBe("2") - expect(item.dataset.screenRow).toBe("2") - expect(item.nextSibling.dataset.screenRow).toBe("2") - expect(line2.previousSibling).toBe(item.nextSibling) - - blockDecoration.getMarker().setHeadBufferPosition([4, 0]) - runAnimationFrames() - const line4 = component.lineNodeForScreenRow(4) - expect(item.previousSibling.dataset.screenRow).toBe("4") - expect(item.dataset.screenRow).toBe("4") - expect(item.nextSibling.dataset.screenRow).toBe("4") - expect(line4.previousSibling).toBe(item.nextSibling) - }) - - it('measures block decorations taking into account both top and bottom margins of the element and its children', function () { - let [item, blockDecoration] = createBlockDecorationBeforeScreenRow(0, {className: "decoration-1"}) - let child = document.createElement("div") - child.style.height = "7px" - child.style.width = "30px" - child.style.marginBottom = "20px" - item.appendChild(child) - atom.styles.addStyleSheet( - 'atom-text-editor .decoration-1 { width: 30px; margin-top: 10px; }', - {context: 'atom-text-editor'} - ) - - runAnimationFrames() // causes the DOM to update and to retrieve new styles - runAnimationFrames() // applies the changes - - expect(component.tileNodesForLines()[0].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + 10 + 7 + 20 + "px") - expect(component.tileNodesForLines()[0].style.webkitTransform).toBe("translate3d(0px, 0px, 0px)") - expect(component.tileNodesForLines()[1].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + "px") - expect(component.tileNodesForLines()[1].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight}px, 0px)`) - expect(component.tileNodesForLines()[2].style.height).toBe(TILE_SIZE * editor.getLineHeightInPixels() + "px") - expect(component.tileNodesForLines()[2].style.webkitTransform).toBe(`translate3d(0px, ${component.tileNodesForLines()[0].offsetHeight + component.tileNodesForLines()[1].offsetHeight}px, 0px)`) - }) - - it('allows the same block decoration item to be moved from one tile to another in the same animation frame', function () { - let [item, blockDecoration] = createBlockDecorationBeforeScreenRow(5, {className: "decoration-1"}) - runAnimationFrames() - expect(component.tileNodesForLines()[0].querySelector('.decoration-1')).toBeNull() - expect(component.tileNodesForLines()[1].querySelector('.decoration-1')).toBe(item) - - blockDecoration.getMarker().setHeadBufferPosition([0, 0]) - runAnimationFrames() - expect(component.tileNodesForLines()[0].querySelector('.decoration-1')).toBe(item) - expect(component.tileNodesForLines()[1].querySelector('.decoration-1')).toBeNull() - }) - }) - - describe('highlight decoration rendering', function () { - let decoration, marker, scrollViewClientLeft - - beforeEach(async function () { - scrollViewClientLeft = componentNode.querySelector('.scroll-view').getBoundingClientRect().left - marker = editor.addMarkerLayer({ - maintainHistory: true - }).markBufferRange([[2, 13], [3, 15]], { - invalidate: 'inside' - }) - decoration = editor.decorateMarker(marker, { - type: 'highlight', - 'class': 'test-highlight' - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - }) - - it('does not render highlights for off-screen lines until they come on-screen', async function () { - wrapperNode.style.height = 2.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - marker = editor.markBufferRange([[9, 2], [9, 4]], { - invalidate: 'inside' - }) - editor.decorateMarker(marker, { - type: 'highlight', - 'class': 'some-highlight' - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(component.presenter.endRow).toBeLessThan(9) - let regions = componentNode.querySelectorAll('.some-highlight .region') - expect(regions.length).toBe(0) - verticalScrollbarNode.scrollTop = 6 * lineHeightInPixels - verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - runAnimationFrames(true) - - expect(component.presenter.endRow).toBeGreaterThan(8) - regions = componentNode.querySelectorAll('.some-highlight .region') - expect(regions.length).toBe(1) - let regionRect = regions[0].style - expect(regionRect.top).toBe(0 + 'px') - expect(regionRect.height).toBe(1 * lineHeightInPixels + 'px') - expect(regionRect.left).toBe(Math.round(2 * charWidth) + 'px') - expect(regionRect.width).toBe(Math.round(2 * charWidth) + 'px') - }) - - it('renders highlights decoration\'s marker is added', function () { - let regions = componentNode.querySelectorAll('.test-highlight .region') - expect(regions.length).toBe(2) - }) - - it('removes highlights when a decoration is removed', async function () { - decoration.destroy() - await decorationsUpdatedPromise(editor) - runAnimationFrames() - let regions = componentNode.querySelectorAll('.test-highlight .region') - expect(regions.length).toBe(0) - }) - - it('does not render a highlight that is within a fold', async function () { - editor.foldBufferRow(1) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - expect(componentNode.querySelectorAll('.test-highlight').length).toBe(0) - }) - - it('removes highlights when a decoration\'s marker is destroyed', async function () { - marker.destroy() - await decorationsUpdatedPromise(editor) - runAnimationFrames() - let regions = componentNode.querySelectorAll('.test-highlight .region') - expect(regions.length).toBe(0) - }) - - it('only renders highlights when a decoration\'s marker is valid', async function () { - editor.getBuffer().insert([3, 2], 'n') - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(marker.isValid()).toBe(false) - let regions = componentNode.querySelectorAll('.test-highlight .region') - expect(regions.length).toBe(0) - editor.getBuffer().undo() - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(marker.isValid()).toBe(true) - regions = componentNode.querySelectorAll('.test-highlight .region') - expect(regions.length).toBe(2) - }) - - it('allows multiple space-delimited decoration classes', async function () { - decoration.setProperties({ - type: 'highlight', - 'class': 'foo bar' - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - expect(componentNode.querySelectorAll('.foo.bar').length).toBe(2) - decoration.setProperties({ - type: 'highlight', - 'class': 'bar baz' - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - expect(componentNode.querySelectorAll('.bar.baz').length).toBe(2) - }) - - it('renders classes on the regions directly if "deprecatedRegionClass" option is defined', async function () { - decoration = editor.decorateMarker(marker, { - type: 'highlight', - 'class': 'test-highlight', - deprecatedRegionClass: 'test-highlight-region' - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - let regions = componentNode.querySelectorAll('.test-highlight .region.test-highlight-region') - expect(regions.length).toBe(2) - }) - - describe('when flashing a decoration via Decoration::flash()', function () { - let highlightNode - - beforeEach(function () { - highlightNode = componentNode.querySelectorAll('.test-highlight')[1] - }) - - it('adds and removes the flash class specified in ::flash', async function () { - expect(highlightNode.classList.contains('flash-class')).toBe(false) - decoration.flash('flash-class', 10) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(highlightNode.classList.contains('flash-class')).toBe(true) - advanceClock(10) - expect(highlightNode.classList.contains('flash-class')).toBe(false) - }) - - describe('when ::flash is called again before the first has finished', function () { - it('removes the class from the decoration highlight before adding it for the second ::flash call', async function () { - decoration.flash('flash-class', 500) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - expect(highlightNode.classList.contains('flash-class')).toBe(true) - - decoration.flash('flash-class', 500) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(highlightNode.classList.contains('flash-class')).toBe(false) - runAnimationFrames() - expect(highlightNode.classList.contains('flash-class')).toBe(true) - advanceClock(500) - expect(highlightNode.classList.contains('flash-class')).toBe(false) - }) - }) - }) - - describe('when a decoration\'s marker moves', function () { - it('moves rendered highlights when the buffer is changed', async function () { - let regionStyle = componentNode.querySelector('.test-highlight .region').style - let originalTop = parseInt(regionStyle.top) - expect(originalTop).toBe(2 * lineHeightInPixels) - - editor.getBuffer().insert([0, 0], '\n') - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - regionStyle = componentNode.querySelector('.test-highlight .region').style - let newTop = parseInt(regionStyle.top) - expect(newTop).toBe(0) - }) - - it('moves rendered highlights when the marker is manually moved', async function () { - let regionStyle = componentNode.querySelector('.test-highlight .region').style - expect(parseInt(regionStyle.top)).toBe(2 * lineHeightInPixels) - - marker.setBufferRange([[5, 8], [5, 13]]) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - regionStyle = componentNode.querySelector('.test-highlight .region').style - expect(parseInt(regionStyle.top)).toBe(2 * lineHeightInPixels) - }) - }) - - describe('when a decoration is updated via Decoration::update', function () { - it('renders the decoration\'s new params', async function () { - expect(componentNode.querySelector('.test-highlight')).toBeTruthy() - decoration.setProperties({ - type: 'highlight', - 'class': 'new-test-highlight' - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - expect(componentNode.querySelector('.test-highlight')).toBeFalsy() - expect(componentNode.querySelector('.new-test-highlight')).toBeTruthy() - }) - }) - }) - - describe('overlay decoration rendering', function () { - let gutterWidth, item - - beforeEach(function () { - item = document.createElement('div') - item.classList.add('overlay-test') - item.style.background = 'red' - gutterWidth = componentNode.querySelector('.gutter').offsetWidth - }) - - describe('when the marker is empty', function () { - it('renders an overlay decoration when added and removes the overlay when the decoration is destroyed', async function () { - let marker = editor.markBufferRange([[2, 13], [2, 13]], { - invalidate: 'never' - }) - let decoration = editor.decorateMarker(marker, { - type: 'overlay', - item: item - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - let overlay = component.getTopmostDOMNode().querySelector('atom-overlay .overlay-test') - expect(overlay).toBe(item) - - decoration.destroy() - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - overlay = component.getTopmostDOMNode().querySelector('atom-overlay .overlay-test') - expect(overlay).toBe(null) - }) - - it('renders the overlay element with the CSS class specified by the decoration', async function () { - let marker = editor.markBufferRange([[2, 13], [2, 13]], { - invalidate: 'never' - }) - let decoration = editor.decorateMarker(marker, { - type: 'overlay', - 'class': 'my-overlay', - item: item - }) - - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - let overlay = component.getTopmostDOMNode().querySelector('atom-overlay.my-overlay') - expect(overlay).not.toBe(null) - let child = overlay.querySelector('.overlay-test') - expect(child).toBe(item) - }) - }) - - describe('when the marker is not empty', function () { - it('renders at the head of the marker by default', async function () { - let marker = editor.markBufferRange([[2, 5], [2, 10]], { - invalidate: 'never' - }) - let decoration = editor.decorateMarker(marker, { - type: 'overlay', - item: item - }) - - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - let position = wrapperNode.pixelPositionForBufferPosition([2, 10]) - let overlay = component.getTopmostDOMNode().querySelector('atom-overlay') - expect(overlay.style.left).toBe(Math.round(position.left + gutterWidth) + 'px') - expect(overlay.style.top).toBe(position.top + editor.getLineHeightInPixels() + 'px') - }) - }) - - describe('positioning the overlay when near the edge of the editor', function () { - let itemHeight, itemWidth, windowHeight, windowWidth - - beforeEach(async function () { - atom.storeWindowDimensions() - itemWidth = Math.round(4 * editor.getDefaultCharWidth()) - itemHeight = 4 * editor.getLineHeightInPixels() - windowWidth = Math.round(gutterWidth + 30 * editor.getDefaultCharWidth()) - windowHeight = 10 * editor.getLineHeightInPixels() - item.style.width = itemWidth + 'px' - item.style.height = itemHeight + 'px' - wrapperNode.style.width = windowWidth + 'px' - wrapperNode.style.height = windowHeight + 'px' - editor.update({autoHeight: false}) - await atom.setWindowDimensions({ - width: windowWidth, - height: windowHeight - }) - - component.measureDimensions() - component.measureWindowSize() - runAnimationFrames() - }) - - afterEach(function () { - atom.restoreWindowDimensions() - }) - - it('slides horizontally left when near the right edge on #win32 and #darwin', async function () { - let marker = editor.markBufferRange([[0, 26], [0, 26]], { - invalidate: 'never' - }) - let decoration = editor.decorateMarker(marker, { - type: 'overlay', - item: item - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - let position = wrapperNode.pixelPositionForBufferPosition([0, 26]) - let overlay = component.getTopmostDOMNode().querySelector('atom-overlay') - if (process.platform == 'darwin') { // Result is 359px on win32, expects 375px - expect(overlay.style.left).toBe(Math.round(position.left + gutterWidth) + 'px') - } - expect(overlay.style.top).toBe(position.top + editor.getLineHeightInPixels() + 'px') - - editor.insertText('a') - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(overlay.style.left).toBe(window.innerWidth - itemWidth + 'px') - expect(overlay.style.top).toBe(position.top + editor.getLineHeightInPixels() + 'px') - - editor.insertText('b') - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(overlay.style.left).toBe(window.innerWidth - itemWidth + 'px') - expect(overlay.style.top).toBe(position.top + editor.getLineHeightInPixels() + 'px') - - // window size change - const innerWidthBefore = window.innerWidth - await atom.setWindowDimensions({ - width: Math.round(gutterWidth + 20 * editor.getDefaultCharWidth()), - height: windowHeight, - }) - // wait for window to resize :( - await conditionPromise(() => { - return window.innerWidth !== innerWidthBefore - }) - - runAnimationFrames() - - expect(overlay.style.left).toBe(window.innerWidth - itemWidth + 'px') - expect(overlay.style.top).toBe(position.top + editor.getLineHeightInPixels() + 'px') - }) - }) - }) - - describe('hidden input field', function () { - it('renders the hidden input field at the position of the last cursor if the cursor is on screen and the editor is focused', async function () { - editor.setVerticalScrollMargin(0) - editor.setHorizontalScrollMargin(0) - let inputNode = componentNode.querySelector('.hidden-input') - wrapperNode.style.height = 5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 10 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - expect(editor.getCursorScreenPosition()).toEqual([0, 0]) - - wrapperNode.setScrollTop(3 * lineHeightInPixels) - wrapperNode.setScrollLeft(3 * charWidth) - runAnimationFrames() - - expect(inputNode.offsetTop).toBe(0) - expect(inputNode.offsetLeft).toBe(0) - - editor.setCursorBufferPosition([5, 4], { - autoscroll: false - }) - await decorationsUpdatedPromise(editor) - runAnimationFrames() - - expect(inputNode.offsetTop).toBe(0) - expect(inputNode.offsetLeft).toBe(0) - - wrapperNode.focus() - runAnimationFrames() - - expect(inputNode.offsetTop).toBe((5 * lineHeightInPixels) - wrapperNode.getScrollTop()) - expect(inputNode.offsetLeft).toBeCloseTo((4 * charWidth) - wrapperNode.getScrollLeft(), 0) - - inputNode.blur() - runAnimationFrames() - - expect(inputNode.offsetTop).toBe(0) - expect(inputNode.offsetLeft).toBe(0) - - editor.setCursorBufferPosition([1, 2], { - autoscroll: false - }) - runAnimationFrames() - - expect(inputNode.offsetTop).toBe(0) - expect(inputNode.offsetLeft).toBe(0) - - inputNode.focus() - runAnimationFrames() - - expect(inputNode.offsetTop).toBe(0) - expect(inputNode.offsetLeft).toBe(0) - }) - }) - - describe('mouse interactions on the lines', function () { - let linesNode - - beforeEach(function () { - linesNode = componentNode.querySelector('.lines') - }) - - describe('when the mouse is single-clicked above the first line', function () { - it('moves the cursor to the start of file buffer position', function () { - let height - editor.setText('foo') - editor.setCursorBufferPosition([0, 3]) - height = 4.5 * lineHeightInPixels - wrapperNode.style.height = height + 'px' - wrapperNode.style.width = 10 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - let coordinates = clientCoordinatesForScreenPosition([0, 2]) - coordinates.clientY = -1 - linesNode.dispatchEvent(buildMouseEvent('mousedown', coordinates)) - - runAnimationFrames() - expect(editor.getCursorScreenPosition()).toEqual([0, 0]) - }) - }) - - describe('when the mouse is single-clicked below the last line', function () { - it('moves the cursor to the end of file buffer position', function () { - editor.setText('foo') - editor.setCursorBufferPosition([0, 0]) - let height = 4.5 * lineHeightInPixels - wrapperNode.style.height = height + 'px' - wrapperNode.style.width = 10 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - let coordinates = clientCoordinatesForScreenPosition([0, 2]) - coordinates.clientY = height * 2 - - linesNode.dispatchEvent(buildMouseEvent('mousedown', coordinates)) - runAnimationFrames() - - expect(editor.getCursorScreenPosition()).toEqual([0, 3]) - }) - }) - - describe('when a non-folded line is single-clicked', function () { - describe('when no modifier keys are held down', function () { - it('moves the cursor to the nearest screen position', function () { - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 10 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - wrapperNode.setScrollTop(3.5 * lineHeightInPixels) - wrapperNode.setScrollLeft(2 * charWidth) - runAnimationFrames() - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([4, 8]))) - runAnimationFrames() - expect(editor.getCursorScreenPosition()).toEqual([4, 8]) - }) - }) - - describe('when the shift key is held down', function () { - it('selects to the nearest screen position', function () { - editor.setCursorScreenPosition([3, 4]) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 6]), { - shiftKey: true - })) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[3, 4], [5, 6]]) - }) - }) - - describe('when the command key is held down', function () { - describe('the current cursor position and screen position do not match', function () { - it('adds a cursor at the nearest screen position', function () { - editor.setCursorScreenPosition([3, 4]) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 6]), { - metaKey: true - })) - runAnimationFrames() - expect(editor.getSelectedScreenRanges()).toEqual([[[3, 4], [3, 4]], [[5, 6], [5, 6]]]) - }) - }) - - describe('when there are multiple cursors, and one of the cursor\'s screen position is the same as the mouse click screen position', function () { - it('removes a cursor at the mouse screen position', function () { - editor.setCursorScreenPosition([3, 4]) - editor.addCursorAtScreenPosition([5, 2]) - editor.addCursorAtScreenPosition([7, 5]) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([3, 4]), { - metaKey: true - })) - runAnimationFrames() - expect(editor.getSelectedScreenRanges()).toEqual([[[5, 2], [5, 2]], [[7, 5], [7, 5]]]) - }) - }) - - describe('when there is a single cursor and the click occurs at the cursor\'s screen position', function () { - it('neither adds a new cursor nor removes the current cursor', function () { - editor.setCursorScreenPosition([3, 4]) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([3, 4]), { - metaKey: true - })) - runAnimationFrames() - expect(editor.getSelectedScreenRanges()).toEqual([[[3, 4], [3, 4]]]) - }) - }) - }) - }) - - describe('when a non-folded line is double-clicked', function () { - describe('when no modifier keys are held down', function () { - it('selects the word containing the nearest screen position', function () { - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 2 - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - expect(editor.getSelectedScreenRange()).toEqual([[5, 6], [5, 13]]) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([6, 6]), { - detail: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - expect(editor.getSelectedScreenRange()).toEqual([[6, 6], [6, 6]]) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([8, 8]), { - detail: 1, - shiftKey: true - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - expect(editor.getSelectedScreenRange()).toEqual([[6, 6], [8, 8]]) - }) - }) - - describe('when the command key is held down', function () { - it('selects the word containing the newly-added cursor', function () { - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 1, - metaKey: true - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 2, - metaKey: true - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - expect(editor.getSelectedScreenRanges()).toEqual([[[0, 0], [0, 0]], [[5, 6], [5, 13]]]) - }) - }) - }) - - describe('when a non-folded line is triple-clicked', function () { - describe('when no modifier keys are held down', function () { - it('selects the line containing the nearest screen position', function () { - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 2 - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 3 - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [6, 0]]) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([6, 6]), { - detail: 1, - shiftKey: true - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [7, 0]]) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([7, 5]), { - detail: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([8, 8]), { - detail: 1, - shiftKey: true - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - expect(editor.getSelectedScreenRange()).toEqual([[7, 5], [8, 8]]) - }) - }) - - describe('when the command key is held down', function () { - it('selects the line containing the newly-added cursor', function () { - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 1, - metaKey: true - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 2, - metaKey: true - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 3, - metaKey: true - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - expect(editor.getSelectedScreenRanges()).toEqual([[[0, 0], [0, 0]], [[5, 0], [6, 0]]]) - }) - }) - }) - - describe('when the mouse is clicked and dragged', function () { - it('selects to the nearest screen position until the mouse button is released', function () { - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), { - which: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), { - which: 1 - })) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [6, 8]]) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([10, 0]), { - which: 1 - })) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [10, 0]]) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([12, 0]), { - which: 1 - })) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [10, 0]]) - }) - - it('autoscrolls when the cursor approaches the boundaries of the editor', function () { - wrapperNode.style.height = '100px' - wrapperNode.style.width = '100px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - expect(wrapperNode.getScrollLeft()).toBe(0) - - linesNode.dispatchEvent(buildMouseEvent('mousedown', { - clientX: 0, - clientY: 0 - }, { - which: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mousemove', { - clientX: 100, - clientY: 50 - }, { - which: 1 - })) - - for (let i = 0; i <= 5; ++i) { - runAnimationFrames() - } - - expect(wrapperNode.getScrollTop()).toBe(0) - expect(wrapperNode.getScrollLeft()).toBeGreaterThan(0) - linesNode.dispatchEvent(buildMouseEvent('mousemove', { - clientX: 100, - clientY: 100 - }, { - which: 1 - })) - - for (let i = 0; i <= 5; ++i) { - runAnimationFrames() - } - - expect(wrapperNode.getScrollTop()).toBeGreaterThan(0) - let previousScrollTop = wrapperNode.getScrollTop() - let previousScrollLeft = wrapperNode.getScrollLeft() - - linesNode.dispatchEvent(buildMouseEvent('mousemove', { - clientX: 10, - clientY: 50 - }, { - which: 1 - })) - - for (let i = 0; i <= 5; ++i) { - runAnimationFrames() - } - - expect(wrapperNode.getScrollTop()).toBe(previousScrollTop) - expect(wrapperNode.getScrollLeft()).toBeLessThan(previousScrollLeft) - linesNode.dispatchEvent(buildMouseEvent('mousemove', { - clientX: 10, - clientY: 10 - }, { - which: 1 - })) - - for (let i = 0; i <= 5; ++i) { - runAnimationFrames() - } - - expect(wrapperNode.getScrollTop()).toBeLessThan(previousScrollTop) - }) - - it('stops selecting if the mouse is dragged into the dev tools', function () { - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), { - which: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), { - which: 1 - })) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [6, 8]]) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([10, 0]), { - which: 0 - })) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [6, 8]]) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([8, 0]), { - which: 1 - })) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [6, 8]]) - }) - - it('stops selecting before the buffer is modified during the drag', function () { - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), { - which: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), { - which: 1 - })) - runAnimationFrames() - - expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [6, 8]]) - - editor.insertText('x') - runAnimationFrames() - - expect(editor.getSelectedScreenRange()).toEqual([[2, 5], [2, 5]]) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([8, 0]), { - which: 1 - })) - expect(editor.getSelectedScreenRange()).toEqual([[2, 5], [2, 5]]) - - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), { - which: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([5, 4]), { - which: 1 - })) - runAnimationFrames() - - expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [5, 4]]) - - editor.delete() - runAnimationFrames() - - expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [2, 4]]) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([8, 0]), { - which: 1 - })) - expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [2, 4]]) - }) - - describe('when the command key is held down', function () { - it('adds a new selection and selects to the nearest screen position, then merges intersecting selections when the mouse button is released', function () { - editor.setSelectedScreenRange([[4, 4], [4, 9]]) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), { - which: 1, - metaKey: true - })) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), { - which: 1 - })) - runAnimationFrames() - - expect(editor.getSelectedScreenRanges()).toEqual([[[4, 4], [4, 9]], [[2, 4], [6, 8]]]) - - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([4, 6]), { - which: 1 - })) - runAnimationFrames() - - expect(editor.getSelectedScreenRanges()).toEqual([[[4, 4], [4, 9]], [[2, 4], [4, 6]]]) - linesNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenPosition([4, 6]), { - which: 1 - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[2, 4], [4, 9]]]) - }) - }) - - describe('when the editor is destroyed while dragging', function () { - it('cleans up the handlers for window.mouseup and window.mousemove', function () { - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), { - which: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), { - which: 1 - })) - runAnimationFrames() - - spyOn(window, 'removeEventListener').andCallThrough() - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 10]), { - which: 1 - })) - - editor.destroy() - runAnimationFrames() - - for (let call of window.removeEventListener.calls) { - call.args.pop() - } - expect(window.removeEventListener).toHaveBeenCalledWith('mouseup') - expect(window.removeEventListener).toHaveBeenCalledWith('mousemove') - }) - }) - }) - - describe('when the mouse is double-clicked and dragged', function () { - it('expands the selection over the nearest word as the cursor moves', function () { - jasmine.attachToDOM(wrapperNode) - wrapperNode.style.height = 6 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 2 - })) - expect(editor.getSelectedScreenRange()).toEqual([[5, 6], [5, 13]]) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([11, 11]), { - which: 1 - })) - runAnimationFrames() - - expect(editor.getSelectedScreenRange()).toEqual([[5, 6], [12, 2]]) - let maximalScrollTop = wrapperNode.getScrollTop() - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([9, 3]), { - which: 1 - })) - runAnimationFrames() - - expect(editor.getSelectedScreenRange()).toEqual([[5, 6], [9, 4]]) - expect(wrapperNode.getScrollTop()).toBe(maximalScrollTop) - linesNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenPosition([9, 3]), { - which: 1 - })) - }) - }) - - describe('when the mouse is triple-clicked and dragged', function () { - it('expands the selection over the nearest line as the cursor moves', function () { - jasmine.attachToDOM(wrapperNode) - wrapperNode.style.height = 6 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 1 - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 2 - })) - linesNode.dispatchEvent(buildMouseEvent('mouseup')) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), { - detail: 3 - })) - expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [6, 0]]) - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([11, 11]), { - which: 1 - })) - runAnimationFrames() - - expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [12, 2]]) - let maximalScrollTop = wrapperNode.getScrollTop() - linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([8, 4]), { - which: 1 - })) - runAnimationFrames() - - expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [8, 0]]) - expect(wrapperNode.getScrollTop()).toBe(maximalScrollTop) - linesNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenPosition([9, 3]), { - which: 1 - })) - }) - }) - - describe('when a fold marker is clicked', function () { - function clickElementAtPosition (marker, position) { - linesNode.dispatchEvent( - buildMouseEvent('mousedown', clientCoordinatesForScreenPosition(position), {target: marker}) - ) - } - - it('unfolds only the selected fold when other folds are on the same line', function () { - editor.foldBufferRange([[4, 6], [4, 10]]) - editor.foldBufferRange([[4, 15], [4, 20]]) - runAnimationFrames() - - let foldMarkers = component.lineNodeForScreenRow(4).querySelectorAll('.fold-marker') - expect(foldMarkers.length).toBe(2) - expect(editor.isFoldedAtBufferRow(4)).toBe(true) - - clickElementAtPosition(foldMarkers[0], [4, 6]) - runAnimationFrames() - foldMarkers = component.lineNodeForScreenRow(4).querySelectorAll('.fold-marker') - expect(foldMarkers.length).toBe(1) - expect(editor.isFoldedAtBufferRow(4)).toBe(true) - - clickElementAtPosition(foldMarkers[0], [4, 15]) - runAnimationFrames() - foldMarkers = component.lineNodeForScreenRow(4).querySelectorAll('.fold-marker') - expect(foldMarkers.length).toBe(0) - expect(editor.isFoldedAtBufferRow(4)).toBe(false) - }) - - it('unfolds only the selected fold when other folds are inside it', function () { - editor.foldBufferRange([[4, 10], [4, 15]]) - editor.foldBufferRange([[4, 4], [4, 5]]) - editor.foldBufferRange([[4, 4], [4, 20]]) - runAnimationFrames() - let foldMarkers = component.lineNodeForScreenRow(4).querySelectorAll('.fold-marker') - expect(foldMarkers.length).toBe(1) - expect(editor.isFoldedAtBufferRow(4)).toBe(true) - - clickElementAtPosition(foldMarkers[0], [4, 4]) - runAnimationFrames() - foldMarkers = component.lineNodeForScreenRow(4).querySelectorAll('.fold-marker') - expect(foldMarkers.length).toBe(1) - expect(editor.isFoldedAtBufferRow(4)).toBe(true) - - clickElementAtPosition(foldMarkers[0], [4, 4]) - runAnimationFrames() - foldMarkers = component.lineNodeForScreenRow(4).querySelectorAll('.fold-marker') - expect(foldMarkers.length).toBe(1) - expect(editor.isFoldedAtBufferRow(4)).toBe(true) - - clickElementAtPosition(foldMarkers[0], [4, 10]) - runAnimationFrames() - foldMarkers = component.lineNodeForScreenRow(4).querySelectorAll('.fold-marker') - expect(foldMarkers.length).toBe(0) - expect(editor.isFoldedAtBufferRow(4)).toBe(false) - }) - }) - - describe('when the horizontal scrollbar is interacted with', function () { - it('clicking on the scrollbar does not move the cursor', function () { - let target = horizontalScrollbarNode - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([4, 8]), { - target: target - })) - expect(editor.getCursorScreenPosition()).toEqual([0, 0]) - }) - }) - }) - - describe('mouse interactions on the gutter', function () { - let gutterNode - - beforeEach(function () { - gutterNode = componentNode.querySelector('.gutter') - }) - - describe('when the component is destroyed', function () { - it('stops listening for selection events', function () { - component.destroy() - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1))) - expect(editor.getSelectedScreenRange()).toEqual([[0, 0], [0, 0]]) - }) - }) - - describe('when the gutter is clicked', function () { - it('selects the clicked row', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(4))) - expect(editor.getSelectedScreenRange()).toEqual([[4, 0], [5, 0]]) - }) - }) - - describe('when the gutter is meta-clicked', function () { - it('creates a new selection for the clicked row', function () { - editor.setSelectedScreenRange([[3, 0], [3, 2]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(4), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[3, 0], [3, 2]], [[4, 0], [5, 0]]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[3, 0], [3, 2]], [[4, 0], [5, 0]], [[6, 0], [7, 0]]]) - }) - }) - - describe('when the gutter is shift-clicked', function () { - beforeEach(function () { - editor.setSelectedScreenRange([[3, 4], [4, 5]]) - }) - - describe('when the clicked row is before the current selection\'s tail', function () { - it('selects to the beginning of the clicked row', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), { - shiftKey: true - })) - expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [3, 4]]) - }) - }) - - describe('when the clicked row is after the current selection\'s tail', function () { - it('selects to the beginning of the row following the clicked row', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6), { - shiftKey: true - })) - expect(editor.getSelectedScreenRange()).toEqual([[3, 4], [7, 0]]) - }) - }) - }) - - describe('when the gutter is clicked and dragged', function () { - describe('when dragging downward', function () { - it('selects the rows between the start and end of the drag', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2))) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6))) - runAnimationFrames() - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(6))) - expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [7, 0]]) - }) - }) - - describe('when dragging upward', function () { - it('selects the rows between the start and end of the drag', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6))) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(2))) - runAnimationFrames() - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(2))) - expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [7, 0]]) - }) - }) - - it('orients the selection appropriately when the mouse moves above or below the initially-clicked row', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(4))) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(2))) - runAnimationFrames() - expect(editor.getLastSelection().isReversed()).toBe(true) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6))) - runAnimationFrames() - expect(editor.getLastSelection().isReversed()).toBe(false) - }) - - it('autoscrolls when the cursor approaches the top or bottom of the editor', function () { - wrapperNode.style.height = 6 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2))) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(8))) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBeGreaterThan(0) - let maxScrollTop = wrapperNode.getScrollTop() - - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(10))) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(maxScrollTop) - - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(7))) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBeLessThan(maxScrollTop) - }) - - it('stops selecting if a textInput event occurs during the drag', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2))) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6))) - runAnimationFrames() - - expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [7, 0]]) - - let inputEvent = new Event('textInput') - inputEvent.data = 'x' - Object.defineProperty(inputEvent, 'target', { - get: function () { - return componentNode.querySelector('.hidden-input') - } - }) - componentNode.dispatchEvent(inputEvent) - runAnimationFrames() - - expect(editor.getSelectedScreenRange()).toEqual([[2, 1], [2, 1]]) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(12))) - expect(editor.getSelectedScreenRange()).toEqual([[2, 1], [2, 1]]) - }) - }) - - describe('when the gutter is meta-clicked and dragged', function () { - beforeEach(function () { - editor.setSelectedScreenRange([[3, 0], [3, 2]]) - }) - - describe('when dragging downward', function () { - it('selects the rows between the start and end of the drag', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(4), { - metaKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6), { - metaKey: true - })) - runAnimationFrames() - - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(6), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[3, 0], [3, 2]], [[4, 0], [7, 0]]]) - }) - - it('merges overlapping selections when the mouse button is released', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2), { - metaKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6), { - metaKey: true - })) - runAnimationFrames() - - expect(editor.getSelectedScreenRanges()).toEqual([[[3, 0], [3, 2]], [[2, 0], [7, 0]]]) - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(6), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[2, 0], [7, 0]]]) - }) - }) - - describe('when dragging upward', function () { - it('selects the rows between the start and end of the drag', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6), { - metaKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(4), { - metaKey: true - })) - runAnimationFrames() - - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(4), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[3, 0], [3, 2]], [[4, 0], [7, 0]]]) - }) - - it('merges overlapping selections', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6), { - metaKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(2), { - metaKey: true - })) - runAnimationFrames() - - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(2), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[2, 0], [7, 0]]]) - }) - }) - }) - - describe('when the gutter is shift-clicked and dragged', function () { - describe('when the shift-click is below the existing selection\'s tail', function () { - describe('when dragging downward', function () { - it('selects the rows between the existing selection\'s tail and the end of the drag', function () { - editor.setSelectedScreenRange([[3, 4], [4, 5]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(7), { - shiftKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(8))) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[3, 4], [9, 0]]) - }) - }) - - describe('when dragging upward', function () { - it('selects the rows between the end of the drag and the tail of the existing selection', function () { - editor.setSelectedScreenRange([[4, 4], [5, 5]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(7), { - shiftKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(5))) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[4, 4], [6, 0]]) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(1))) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [4, 4]]) - }) - }) - }) - - describe('when the shift-click is above the existing selection\'s tail', function () { - describe('when dragging upward', function () { - it('selects the rows between the end of the drag and the tail of the existing selection', function () { - editor.setSelectedScreenRange([[4, 4], [5, 5]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2), { - shiftKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(1))) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [4, 4]]) - }) - }) - - describe('when dragging downward', function () { - it('selects the rows between the existing selection\'s tail and the end of the drag', function () { - editor.setSelectedScreenRange([[3, 4], [4, 5]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), { - shiftKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(2))) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [3, 4]]) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(8))) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[3, 4], [9, 0]]) - }) - }) - }) - }) - - describe('when soft wrap is enabled', function () { - beforeEach(function () { - gutterNode = componentNode.querySelector('.gutter') - editor.setSoftWrapped(true) - runAnimationFrames() - componentNode.style.width = 21 * charWidth + wrapperNode.getVerticalScrollbarWidth() + 'px' - component.measureDimensions() - runAnimationFrames() - }) - - describe('when the gutter is clicked', function () { - it('selects the clicked buffer row', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1))) - expect(editor.getSelectedScreenRange()).toEqual([[0, 0], [2, 0]]) - }) - }) - - describe('when the gutter is meta-clicked', function () { - it('creates a new selection for the clicked buffer row', function () { - editor.setSelectedScreenRange([[1, 0], [1, 2]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[1, 0], [1, 2]], [[2, 0], [5, 0]]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(7), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[1, 0], [1, 2]], [[2, 0], [5, 0]], [[5, 0], [10, 0]]]) - }) - }) - - describe('when the gutter is shift-clicked', function () { - beforeEach(function () { - return editor.setSelectedScreenRange([[7, 4], [7, 6]]) - }) - - describe('when the clicked row is before the current selection\'s tail', function () { - it('selects to the beginning of the clicked buffer row', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), { - shiftKey: true - })) - expect(editor.getSelectedScreenRange()).toEqual([[0, 0], [7, 4]]) - }) - }) - - describe('when the clicked row is after the current selection\'s tail', function () { - it('selects to the beginning of the screen row following the clicked buffer row', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(11), { - shiftKey: true - })) - expect(editor.getSelectedScreenRange()).toEqual([[7, 4], [17, 0]]) - }) - }) - }) - - describe('when the gutter is clicked and dragged', function () { - describe('when dragging downward', function () { - it('selects the buffer row containing the click, then screen rows until the end of the drag', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1))) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6))) - runAnimationFrames() - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(6))) - expect(editor.getSelectedScreenRange()).toEqual([[0, 0], [6, 14]]) - }) - }) - - describe('when dragging upward', function () { - it('selects the buffer row containing the click, then screen rows until the end of the drag', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6))) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(1))) - runAnimationFrames() - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(1))) - expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [10, 0]]) - }) - }) - }) - - describe('when the gutter is meta-clicked and dragged', function () { - beforeEach(function () { - editor.setSelectedScreenRange([[7, 4], [7, 6]]) - }) - - describe('when dragging downward', function () { - it('adds a selection from the buffer row containing the click to the screen row containing the end of the drag', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), { - metaKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(3), { - metaKey: true - })) - runAnimationFrames() - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(3), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[7, 4], [7, 6]], [[0, 0], [3, 14]]]) - }) - - it('merges overlapping selections on mouseup', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), { - metaKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(7), { - metaKey: true - })) - runAnimationFrames() - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(7), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[0, 0], [7, 12]]]) - }) - }) - - describe('when dragging upward', function () { - it('adds a selection from the buffer row containing the click to the screen row containing the end of the drag', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(17), { - metaKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(11), { - metaKey: true - })) - runAnimationFrames() - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(11), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[7, 4], [7, 6]], [[11, 4], [20, 0]]]) - }) - - it('merges overlapping selections on mouseup', function () { - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(17), { - metaKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(5), { - metaKey: true - })) - runAnimationFrames() - gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(5), { - metaKey: true - })) - expect(editor.getSelectedScreenRanges()).toEqual([[[5, 0], [20, 0]]]) - }) - }) - }) - - describe('when the gutter is shift-clicked and dragged', function () { - describe('when the shift-click is below the existing selection\'s tail', function () { - describe('when dragging downward', function () { - it('selects the screen rows between the existing selection\'s tail and the end of the drag', function () { - editor.setSelectedScreenRange([[1, 4], [1, 7]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(7), { - shiftKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(11))) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [11, 5]]) - }) - }) - - describe('when dragging upward', function () { - it('selects the screen rows between the end of the drag and the tail of the existing selection', function () { - editor.setSelectedScreenRange([[1, 4], [1, 7]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(11), { - shiftKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(7))) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [7, 12]]) - }) - }) - }) - - describe('when the shift-click is above the existing selection\'s tail', function () { - describe('when dragging upward', function () { - it('selects the screen rows between the end of the drag and the tail of the existing selection', function () { - editor.setSelectedScreenRange([[7, 4], [7, 6]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(3), { - shiftKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(1))) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [7, 4]]) - }) - }) - - describe('when dragging downward', function () { - it('selects the screen rows between the existing selection\'s tail and the end of the drag', function () { - editor.setSelectedScreenRange([[7, 4], [7, 6]]) - gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), { - shiftKey: true - })) - gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(3))) - runAnimationFrames() - expect(editor.getSelectedScreenRange()).toEqual([[3, 2], [7, 4]]) - }) - }) - }) - }) - }) - }) - - describe('focus handling', function () { - let inputNode - beforeEach(function () { - inputNode = componentNode.querySelector('.hidden-input') - }) - - it('transfers focus to the hidden input', function () { - expect(document.activeElement).toBe(document.body) - wrapperNode.focus() - expect(document.activeElement).toBe(inputNode) - }) - - it('adds the "is-focused" class to the editor when the hidden input is focused', function () { - expect(document.activeElement).toBe(document.body) - inputNode.focus() - runAnimationFrames() - - expect(componentNode.classList.contains('is-focused')).toBe(true) - expect(wrapperNode.classList.contains('is-focused')).toBe(true) - inputNode.blur() - runAnimationFrames() - - expect(componentNode.classList.contains('is-focused')).toBe(false) - expect(wrapperNode.classList.contains('is-focused')).toBe(false) - }) - }) - - describe('selection handling', function () { - let cursor - - beforeEach(function () { - editor.setCursorScreenPosition([0, 0]) - runAnimationFrames() - }) - - it('adds the "has-selection" class to the editor when there is a selection', function () { - expect(componentNode.classList.contains('has-selection')).toBe(false) - editor.selectDown() - runAnimationFrames() - expect(componentNode.classList.contains('has-selection')).toBe(true) - editor.moveDown() - runAnimationFrames() - expect(componentNode.classList.contains('has-selection')).toBe(false) - }) - }) - - describe('scrolling', function () { - it('updates the vertical scrollbar when the scrollTop is changed in the model', function () { - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - expect(verticalScrollbarNode.scrollTop).toBe(0) - wrapperNode.setScrollTop(10) - runAnimationFrames() - expect(verticalScrollbarNode.scrollTop).toBe(10) - }) - - it('updates the horizontal scrollbar and the x transform of the lines based on the scrollLeft of the model', function () { - componentNode.style.width = 30 * charWidth + 'px' - component.measureDimensions() - runAnimationFrames() - - let top = 0 - let tilesNodes = component.tileNodesForLines() - for (let tileNode of tilesNodes) { - expect(tileNode.style['-webkit-transform']).toBe('translate3d(0px, ' + top + 'px, 0px)') - top += tileNode.offsetHeight - } - expect(horizontalScrollbarNode.scrollLeft).toBe(0) - wrapperNode.setScrollLeft(100) - - runAnimationFrames() - - top = 0 - for (let tileNode of tilesNodes) { - expect(tileNode.style['-webkit-transform']).toBe('translate3d(-100px, ' + top + 'px, 0px)') - top += tileNode.offsetHeight - } - expect(horizontalScrollbarNode.scrollLeft).toBe(100) - }) - - it('updates the scrollLeft of the model when the scrollLeft of the horizontal scrollbar changes', function () { - componentNode.style.width = 30 * charWidth + 'px' - component.measureDimensions() - runAnimationFrames() - expect(wrapperNode.getScrollLeft()).toBe(0) - horizontalScrollbarNode.scrollLeft = 100 - horizontalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - runAnimationFrames(true) - expect(wrapperNode.getScrollLeft()).toBe(100) - }) - - it('does not obscure the last line with the horizontal scrollbar', function () { - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 10 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - wrapperNode.setScrollBottom(wrapperNode.getScrollHeight()) - runAnimationFrames() - - let lastLineNode = component.lineNodeForScreenRow(editor.getLastScreenRow()) - let bottomOfLastLine = lastLineNode.getBoundingClientRect().bottom - topOfHorizontalScrollbar = horizontalScrollbarNode.getBoundingClientRect().top - expect(bottomOfLastLine).toBe(topOfHorizontalScrollbar) - wrapperNode.style.width = 100 * charWidth + 'px' - component.measureDimensions() - runAnimationFrames() - - bottomOfLastLine = lastLineNode.getBoundingClientRect().bottom - let bottomOfEditor = componentNode.getBoundingClientRect().bottom - expect(bottomOfLastLine).toBe(bottomOfEditor) - }) - - it('does not obscure the last character of the longest line with the vertical scrollbar', function () { - wrapperNode.style.height = 7 * lineHeightInPixels + 'px' - wrapperNode.style.width = 10 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - wrapperNode.setScrollLeft(Infinity) - - runAnimationFrames() - let rightOfLongestLine = component.lineNodeForScreenRow(6).querySelector('.line > span:last-child').getBoundingClientRect().right - let leftOfVerticalScrollbar = verticalScrollbarNode.getBoundingClientRect().left - expect(Math.round(rightOfLongestLine)).toBeCloseTo(leftOfVerticalScrollbar - 1, 0) - }) - - it('only displays dummy scrollbars when scrollable in that direction', function () { - expect(verticalScrollbarNode.style.display).toBe('none') - expect(horizontalScrollbarNode.style.display).toBe('none') - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = '1000px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - expect(verticalScrollbarNode.style.display).toBe('') - expect(horizontalScrollbarNode.style.display).toBe('none') - componentNode.style.width = 10 * charWidth + 'px' - component.measureDimensions() - runAnimationFrames() - - expect(verticalScrollbarNode.style.display).toBe('') - expect(horizontalScrollbarNode.style.display).toBe('') - wrapperNode.style.height = 20 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - expect(verticalScrollbarNode.style.display).toBe('none') - expect(horizontalScrollbarNode.style.display).toBe('') - }) - - it('makes the dummy scrollbar divs only as tall/wide as the actual scrollbars', function () { - wrapperNode.style.height = 4 * lineHeightInPixels + 'px' - wrapperNode.style.width = 10 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - atom.styles.addStyleSheet('::-webkit-scrollbar {\n width: 8px;\n height: 8px;\n}', { - context: 'atom-text-editor' - }) - - runAnimationFrames() - runAnimationFrames() - - let scrollbarCornerNode = componentNode.querySelector('.scrollbar-corner') - expect(verticalScrollbarNode.offsetWidth).toBe(8) - expect(horizontalScrollbarNode.offsetHeight).toBe(8) - expect(scrollbarCornerNode.offsetWidth).toBe(8) - expect(scrollbarCornerNode.offsetHeight).toBe(8) - atom.themes.removeStylesheet('test') - }) - - it('assigns the bottom/right of the scrollbars to the width of the opposite scrollbar if it is visible', function () { - let scrollbarCornerNode = componentNode.querySelector('.scrollbar-corner') - expect(verticalScrollbarNode.style.bottom).toBe('0px') - expect(horizontalScrollbarNode.style.right).toBe('0px') - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = '1000px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - expect(verticalScrollbarNode.style.bottom).toBe('0px') - expect(horizontalScrollbarNode.style.right).toBe(verticalScrollbarNode.offsetWidth + 'px') - expect(scrollbarCornerNode.style.display).toBe('none') - componentNode.style.width = 10 * charWidth + 'px' - component.measureDimensions() - runAnimationFrames() - - expect(verticalScrollbarNode.style.bottom).toBe(horizontalScrollbarNode.offsetHeight + 'px') - expect(horizontalScrollbarNode.style.right).toBe(verticalScrollbarNode.offsetWidth + 'px') - expect(scrollbarCornerNode.style.display).toBe('') - wrapperNode.style.height = 20 * lineHeightInPixels + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - expect(verticalScrollbarNode.style.bottom).toBe(horizontalScrollbarNode.offsetHeight + 'px') - expect(horizontalScrollbarNode.style.right).toBe('0px') - expect(scrollbarCornerNode.style.display).toBe('none') - }) - - it('accounts for the width of the gutter in the scrollWidth of the horizontal scrollbar', function () { - let gutterNode = componentNode.querySelector('.gutter') - componentNode.style.width = 10 * charWidth + 'px' - component.measureDimensions() - runAnimationFrames() - - expect(horizontalScrollbarNode.scrollWidth).toBe(wrapperNode.getScrollWidth()) - expect(horizontalScrollbarNode.style.left).toBe('0px') - }) - }) - - describe('mousewheel events', function () { - beforeEach(function () { - editor.update({scrollSensitivity: 100}) - }) - - describe('updating scrollTop and scrollLeft', function () { - beforeEach(function () { - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 20 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - }) - - it('updates the scrollLeft or scrollTop on mousewheel events depending on which delta is greater (x or y)', function () { - expect(verticalScrollbarNode.scrollTop).toBe(0) - expect(horizontalScrollbarNode.scrollLeft).toBe(0) - componentNode.dispatchEvent(new WheelEvent('mousewheel', { - wheelDeltaX: -5, - wheelDeltaY: -10 - })) - runAnimationFrames() - - expect(verticalScrollbarNode.scrollTop).toBe(10) - expect(horizontalScrollbarNode.scrollLeft).toBe(0) - componentNode.dispatchEvent(new WheelEvent('mousewheel', { - wheelDeltaX: -15, - wheelDeltaY: -5 - })) - runAnimationFrames() - - expect(verticalScrollbarNode.scrollTop).toBe(10) - expect(horizontalScrollbarNode.scrollLeft).toBe(15) - }) - - it('updates the scrollLeft or scrollTop according to the scroll sensitivity', function () { - editor.update({scrollSensitivity: 50}) - componentNode.dispatchEvent(new WheelEvent('mousewheel', { - wheelDeltaX: -5, - wheelDeltaY: -10 - })) - runAnimationFrames() - - expect(horizontalScrollbarNode.scrollLeft).toBe(0) - componentNode.dispatchEvent(new WheelEvent('mousewheel', { - wheelDeltaX: -15, - wheelDeltaY: -5 - })) - runAnimationFrames() - - expect(verticalScrollbarNode.scrollTop).toBe(5) - expect(horizontalScrollbarNode.scrollLeft).toBe(7) - }) - }) - - describe('when the mousewheel event\'s target is a line', function () { - it('keeps the line on the DOM if it is scrolled off-screen', function () { - component.presenter.stoppedScrollingDelay = 3000 // account for slower build machines - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 20 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - let lineNode = componentNode.querySelector('.line') - let wheelEvent = new WheelEvent('mousewheel', { - wheelDeltaX: 0, - wheelDeltaY: -500 - }) - Object.defineProperty(wheelEvent, 'target', { - get: function () { - return lineNode - } - }) - componentNode.dispatchEvent(wheelEvent) - runAnimationFrames() - - expect(componentNode.contains(lineNode)).toBe(true) - }) - - it('does not set the mouseWheelScreenRow if scrolling horizontally', function () { - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 20 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - let lineNode = componentNode.querySelector('.line') - let wheelEvent = new WheelEvent('mousewheel', { - wheelDeltaX: 10, - wheelDeltaY: 0 - }) - Object.defineProperty(wheelEvent, 'target', { - get: function () { - return lineNode - } - }) - componentNode.dispatchEvent(wheelEvent) - runAnimationFrames() - - expect(component.presenter.mouseWheelScreenRow).toBe(null) - }) - - it('clears the mouseWheelScreenRow after a delay even if the event does not cause scrolling', async function () { - expect(wrapperNode.getScrollTop()).toBe(0) - let lineNode = componentNode.querySelector('.line') - let wheelEvent = new WheelEvent('mousewheel', { - wheelDeltaX: 0, - wheelDeltaY: 10 - }) - Object.defineProperty(wheelEvent, 'target', { - get: function () { - return lineNode - } - }) - componentNode.dispatchEvent(wheelEvent) - expect(wrapperNode.getScrollTop()).toBe(0) - expect(component.presenter.mouseWheelScreenRow).toBe(0) - - advanceClock(component.presenter.stoppedScrollingDelay) - expect(component.presenter.mouseWheelScreenRow).toBeNull() - }) - - it('does not preserve the line if it is on screen', function () { - let lineNode, lineNodes, wheelEvent - expect(componentNode.querySelectorAll('.line-number').length).toBe(14) - lineNodes = componentNode.querySelectorAll('.line') - expect(lineNodes.length).toBe(13) - lineNode = lineNodes[0] - wheelEvent = new WheelEvent('mousewheel', { - wheelDeltaX: 0, - wheelDeltaY: 100 - }) - Object.defineProperty(wheelEvent, 'target', { - get: function () { - return lineNode - } - }) - componentNode.dispatchEvent(wheelEvent) - expect(component.presenter.mouseWheelScreenRow).toBe(0) - editor.insertText('hello') - expect(componentNode.querySelectorAll('.line-number').length).toBe(14) - expect(componentNode.querySelectorAll('.line').length).toBe(13) - }) - }) - - describe('when the mousewheel event\'s target is a line number', function () { - it('keeps the line number on the DOM if it is scrolled off-screen', function () { - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 20 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - let lineNumberNode = componentNode.querySelectorAll('.line-number')[1] - let wheelEvent = new WheelEvent('mousewheel', { - wheelDeltaX: 0, - wheelDeltaY: -500 - }) - Object.defineProperty(wheelEvent, 'target', { - get: function () { - return lineNumberNode - } - }) - componentNode.dispatchEvent(wheelEvent) - runAnimationFrames() - - expect(componentNode.contains(lineNumberNode)).toBe(true) - }) - }) - - describe('when the mousewheel event\'s target is a block decoration', function () { - it('keeps it on the DOM if it is scrolled off-screen', function () { - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 20 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - let item = document.createElement("div") - item.style.width = "30px" - item.style.height = "30px" - item.className = "decoration-1" - editor.decorateMarker( - editor.markScreenPosition([0, 0], {invalidate: "never"}), - {type: "block", item: item} - ) - - runAnimationFrames() - - let wheelEvent = new WheelEvent('mousewheel', { - wheelDeltaX: 0, - wheelDeltaY: -500 - }) - Object.defineProperty(wheelEvent, 'target', { - get: function () { - return item - } - }) - componentNode.dispatchEvent(wheelEvent) - runAnimationFrames() - - expect(component.getTopmostDOMNode().contains(item)).toBe(true) - }) - }) - - describe('when the mousewheel event\'s target is an SVG element inside a block decoration', function () { - it('keeps the block decoration on the DOM if it is scrolled off-screen', function () { - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 20 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - const item = document.createElement('div') - const svgElement = document.createElementNS("http://www.w3.org/2000/svg", "svg") - item.appendChild(svgElement) - editor.decorateMarker( - editor.markScreenPosition([0, 0], {invalidate: "never"}), - {type: "block", item: item} - ) - - runAnimationFrames() - - let wheelEvent = new WheelEvent('mousewheel', { - wheelDeltaX: 0, - wheelDeltaY: -500 - }) - Object.defineProperty(wheelEvent, 'target', { - get: function () { - return svgElement - } - }) - componentNode.dispatchEvent(wheelEvent) - runAnimationFrames() - - expect(component.getTopmostDOMNode().contains(item)).toBe(true) - }) - }) - - it('only prevents the default action of the mousewheel event if it actually lead to scrolling', function () { - spyOn(WheelEvent.prototype, 'preventDefault').andCallThrough() - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - wrapperNode.style.width = 20 * charWidth + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - componentNode.dispatchEvent(new WheelEvent('mousewheel', { - wheelDeltaX: 0, - wheelDeltaY: 50 - })) - expect(wrapperNode.getScrollTop()).toBe(0) - expect(WheelEvent.prototype.preventDefault).not.toHaveBeenCalled() - componentNode.dispatchEvent(new WheelEvent('mousewheel', { - wheelDeltaX: 0, - wheelDeltaY: -3000 - })) - runAnimationFrames() - - let maxScrollTop = wrapperNode.getScrollTop() - expect(WheelEvent.prototype.preventDefault).toHaveBeenCalled() - WheelEvent.prototype.preventDefault.reset() - componentNode.dispatchEvent(new WheelEvent('mousewheel', { - wheelDeltaX: 0, - wheelDeltaY: -30 - })) - expect(wrapperNode.getScrollTop()).toBe(maxScrollTop) - expect(WheelEvent.prototype.preventDefault).not.toHaveBeenCalled() - componentNode.dispatchEvent(new WheelEvent('mousewheel', { - wheelDeltaX: 50, - wheelDeltaY: 0 - })) - expect(wrapperNode.getScrollLeft()).toBe(0) - expect(WheelEvent.prototype.preventDefault).not.toHaveBeenCalled() - componentNode.dispatchEvent(new WheelEvent('mousewheel', { - wheelDeltaX: -3000, - wheelDeltaY: 0 - })) - runAnimationFrames() - - let maxScrollLeft = wrapperNode.getScrollLeft() - expect(WheelEvent.prototype.preventDefault).toHaveBeenCalled() - WheelEvent.prototype.preventDefault.reset() - componentNode.dispatchEvent(new WheelEvent('mousewheel', { - wheelDeltaX: -30, - wheelDeltaY: 0 - })) - expect(wrapperNode.getScrollLeft()).toBe(maxScrollLeft) - expect(WheelEvent.prototype.preventDefault).not.toHaveBeenCalled() - }) - }) - - describe('input events', function () { - function buildTextInputEvent ({data, target}) { - let event = new Event('textInput') - event.data = data - Object.defineProperty(event, 'target', { - get: function () { - return target - } - }) - return event - } - - function buildKeydownEvent ({keyCode, target}) { - let event = new KeyboardEvent('keydown') - Object.defineProperty(event, 'keyCode', { - get: function () { - return keyCode - } - }) - Object.defineProperty(event, 'target', { - get: function () { - return target - } - }) - return event - } - - let inputNode - - beforeEach(function () { - inputNode = componentNode.querySelector('.hidden-input') - }) - - it('inserts the newest character in the input\'s value into the buffer', function () { - componentNode.dispatchEvent(buildTextInputEvent({ - data: 'x', - target: inputNode - })) - runAnimationFrames() - - expect(editor.lineTextForBufferRow(0)).toBe('xvar quicksort = function () {') - componentNode.dispatchEvent(buildTextInputEvent({ - data: 'y', - target: inputNode - })) - - expect(editor.lineTextForBufferRow(0)).toBe('xyvar quicksort = function () {') - }) - - it('replaces the last character if a keypress event is bracketed by keydown events with matching keyCodes, which occurs when the accented character menu is shown', function () { - componentNode.dispatchEvent(buildKeydownEvent({keyCode: 85, target: inputNode})) - componentNode.dispatchEvent(buildTextInputEvent({data: 'u', target: inputNode})) - componentNode.dispatchEvent(new KeyboardEvent('keypress')) - componentNode.dispatchEvent(buildKeydownEvent({keyCode: 85, target: inputNode})) - componentNode.dispatchEvent(new KeyboardEvent('keyup')) - runAnimationFrames() - - expect(editor.lineTextForBufferRow(0)).toBe('uvar quicksort = function () {') - inputNode.setSelectionRange(0, 1) - componentNode.dispatchEvent(buildTextInputEvent({ - data: 'ü', - target: inputNode - })) - runAnimationFrames() - - expect(editor.lineTextForBufferRow(0)).toBe('üvar quicksort = function () {') - }) - - it('does not handle input events when input is disabled', function () { - component.setInputEnabled(false) - componentNode.dispatchEvent(buildTextInputEvent({ - data: 'x', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () {') - runAnimationFrames() - expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () {') - }) - - it('groups events that occur close together in time into single undo entries', function () { - let currentTime = 0 - spyOn(Date, 'now').andCallFake(function () { - return currentTime - }) - editor.update({undoGroupingInterval: 100}) - editor.setText('') - componentNode.dispatchEvent(buildTextInputEvent({ - data: 'x', - target: inputNode - })) - currentTime += 99 - componentNode.dispatchEvent(buildTextInputEvent({ - data: 'y', - target: inputNode - })) - currentTime += 99 - componentNode.dispatchEvent(new CustomEvent('editor:duplicate-lines', { - bubbles: true, - cancelable: true - })) - currentTime += 101 - componentNode.dispatchEvent(new CustomEvent('editor:duplicate-lines', { - bubbles: true, - cancelable: true - })) - expect(editor.getText()).toBe('xy\nxy\nxy') - componentNode.dispatchEvent(new CustomEvent('core:undo', { - bubbles: true, - cancelable: true - })) - expect(editor.getText()).toBe('xy\nxy') - componentNode.dispatchEvent(new CustomEvent('core:undo', { - bubbles: true, - cancelable: true - })) - expect(editor.getText()).toBe('') - }) - - describe('when IME composition is used to insert international characters', function () { - function buildIMECompositionEvent (event, {data, target} = {}) { - event = new Event(event) - event.data = data - Object.defineProperty(event, 'target', { - get: function () { - return target - } - }) - return event - } - - let inputNode - - beforeEach(function () { - inputNode = componentNode.querySelector('.hidden-input') - }) - - describe('when nothing is selected', function () { - it('inserts the chosen completion', function () { - componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', { - target: inputNode - })) - componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { - data: 's', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('svar quicksort = function () {') - componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { - data: 'sd', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('sdvar quicksort = function () {') - componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', { - target: inputNode - })) - componentNode.dispatchEvent(buildTextInputEvent({ - data: '速度', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('速度var quicksort = function () {') - }) - - it('reverts back to the original text when the completion helper is dismissed', function () { - componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', { - target: inputNode - })) - componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { - data: 's', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('svar quicksort = function () {') - componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { - data: 'sd', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('sdvar quicksort = function () {') - componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', { - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () {') - }) - - it('allows multiple accented character to be inserted with the \' on a US international layout', function () { - inputNode.value = '\'' - inputNode.setSelectionRange(0, 1) - componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', { - target: inputNode - })) - componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { - data: '\'', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('\'var quicksort = function () {') - componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', { - target: inputNode - })) - componentNode.dispatchEvent(buildTextInputEvent({ - data: 'á', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('ávar quicksort = function () {') - inputNode.value = '\'' - inputNode.setSelectionRange(0, 1) - componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', { - target: inputNode - })) - componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { - data: '\'', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('á\'var quicksort = function () {') - componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', { - target: inputNode - })) - componentNode.dispatchEvent(buildTextInputEvent({ - data: 'á', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('áávar quicksort = function () {') - }) - }) - - describe('when a string is selected', function () { - beforeEach(function () { - editor.setSelectedBufferRanges([[[0, 4], [0, 9]], [[0, 16], [0, 19]]]) - }) - - it('inserts the chosen completion', function () { - componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', { - target: inputNode - })) - componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { - data: 's', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('var ssort = sction () {') - componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { - data: 'sd', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('var sdsort = sdction () {') - componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', { - target: inputNode - })) - componentNode.dispatchEvent(buildTextInputEvent({ - data: '速度', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('var 速度sort = 速度ction () {') - }) - - it('reverts back to the original text when the completion helper is dismissed', function () { - componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', { - target: inputNode - })) - componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { - data: 's', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('var ssort = sction () {') - componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', { - data: 'sd', - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('var sdsort = sdction () {') - componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', { - target: inputNode - })) - expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () {') - }) - }) - }) - }) - - describe('commands', function () { - describe('editor:consolidate-selections', function () { - it('consolidates selections on the editor model, aborting the key binding if there is only one selection', function () { - spyOn(editor, 'consolidateSelections').andCallThrough() - let event = new CustomEvent('editor:consolidate-selections', { - bubbles: true, - cancelable: true - }) - event.abortKeyBinding = jasmine.createSpy('event.abortKeyBinding') - componentNode.dispatchEvent(event) - expect(editor.consolidateSelections).toHaveBeenCalled() - expect(event.abortKeyBinding).toHaveBeenCalled() - }) - }) - }) - - describe('when decreasing the fontSize', function () { - it('decreases the widths of the korean char, the double width char and the half width char', function () { - originalDefaultCharWidth = editor.getDefaultCharWidth() - koreanDefaultCharWidth = editor.getKoreanCharWidth() - doubleWidthDefaultCharWidth = editor.getDoubleWidthCharWidth() - halfWidthDefaultCharWidth = editor.getHalfWidthCharWidth() - component.setFontSize(10) - runAnimationFrames() - expect(editor.getDefaultCharWidth()).toBeLessThan(originalDefaultCharWidth) - expect(editor.getKoreanCharWidth()).toBeLessThan(koreanDefaultCharWidth) - expect(editor.getDoubleWidthCharWidth()).toBeLessThan(doubleWidthDefaultCharWidth) - expect(editor.getHalfWidthCharWidth()).toBeLessThan(halfWidthDefaultCharWidth) - }) - }) - - describe('when increasing the fontSize', function() { - it('increases the widths of the korean char, the double width char and the half width char', function () { - originalDefaultCharWidth = editor.getDefaultCharWidth() - koreanDefaultCharWidth = editor.getKoreanCharWidth() - doubleWidthDefaultCharWidth = editor.getDoubleWidthCharWidth() - halfWidthDefaultCharWidth = editor.getHalfWidthCharWidth() - component.setFontSize(25) - runAnimationFrames() - expect(editor.getDefaultCharWidth()).toBeGreaterThan(originalDefaultCharWidth) - expect(editor.getKoreanCharWidth()).toBeGreaterThan(koreanDefaultCharWidth) - expect(editor.getDoubleWidthCharWidth()).toBeGreaterThan(doubleWidthDefaultCharWidth) - expect(editor.getHalfWidthCharWidth()).toBeGreaterThan(halfWidthDefaultCharWidth) - }) - }) - - describe('hiding and showing the editor', function () { - beforeEach(function () { - spyOn(component, 'becameVisible').andCallThrough() - }) - - describe('when the editor is hidden when it is mounted', function () { - it('defers measurement and rendering until the editor becomes visible', async function () { - wrapperNode.remove() - let hiddenParent = document.createElement('div') - hiddenParent.style.display = 'none' - contentNode.appendChild(hiddenParent) - wrapperNode = new TextEditorElement() - wrapperNode.tileSize = TILE_SIZE - wrapperNode.initialize(editor, atom) - hiddenParent.appendChild(wrapperNode) - component = wrapperNode.component - spyOn(component, 'becameVisible').andCallThrough() - componentNode = component.getDomNode() - expect(componentNode.querySelectorAll('.line').length).toBe(0) - hiddenParent.style.display = 'block' - await conditionPromise(() => component.becameVisible.callCount > 0) - expect(componentNode.querySelectorAll('.line').length).toBeGreaterThan(0) - }) - }) - - describe('when the lineHeight changes while the editor is hidden', function () { - it('does not attempt to measure the lineHeightInPixels until the editor becomes visible again', async function () { - wrapperNode.style.display = 'none' - let initialLineHeightInPixels = editor.getLineHeightInPixels() - component.setLineHeight(2) - expect(editor.getLineHeightInPixels()).toBe(initialLineHeightInPixels) - wrapperNode.style.display = '' - await conditionPromise(() => component.becameVisible.callCount > 0) - expect(editor.getLineHeightInPixels()).not.toBe(initialLineHeightInPixels) - }) - }) - - describe('when the fontSize changes while the editor is hidden', function () { - it('does not attempt to measure the lineHeightInPixels or defaultCharWidth until the editor becomes visible again', async function () { - wrapperNode.style.display = 'none' - let initialLineHeightInPixels = editor.getLineHeightInPixels() - let initialCharWidth = editor.getDefaultCharWidth() - component.setFontSize(22) - expect(editor.getLineHeightInPixels()).toBe(initialLineHeightInPixels) - expect(editor.getDefaultCharWidth()).toBe(initialCharWidth) - wrapperNode.style.display = '' - await conditionPromise(() => component.becameVisible.callCount > 0) - expect(editor.getLineHeightInPixels()).not.toBe(initialLineHeightInPixels) - expect(editor.getDefaultCharWidth()).not.toBe(initialCharWidth) - }) - - it('does not re-measure character widths until the editor is shown again', async function () { - wrapperNode.style.display = 'none' - component.setFontSize(22) - editor.getBuffer().insert([0, 0], 'a') - wrapperNode.style.display = '' - await conditionPromise(() => component.becameVisible.callCount > 0) - editor.setCursorBufferPosition([0, Infinity]) - runAnimationFrames() - let cursorLeft = componentNode.querySelector('.cursor').getBoundingClientRect().left - let line0Right = componentNode.querySelector('.line > span:last-child').getBoundingClientRect().right - expect(cursorLeft).toBeCloseTo(line0Right, 0) - }) - }) - - describe('when the fontFamily changes while the editor is hidden', function () { - it('does not attempt to measure the defaultCharWidth until the editor becomes visible again', async function () { - wrapperNode.style.display = 'none' - let initialLineHeightInPixels = editor.getLineHeightInPixels() - let initialCharWidth = editor.getDefaultCharWidth() - component.setFontFamily('serif') - expect(editor.getDefaultCharWidth()).toBe(initialCharWidth) - wrapperNode.style.display = '' - await conditionPromise(() => component.becameVisible.callCount > 0) - expect(editor.getDefaultCharWidth()).not.toBe(initialCharWidth) - }) - - it('does not re-measure character widths until the editor is shown again', async function () { - wrapperNode.style.display = 'none' - component.setFontFamily('serif') - wrapperNode.style.display = '' - await conditionPromise(() => component.becameVisible.callCount > 0) - editor.setCursorBufferPosition([0, Infinity]) - runAnimationFrames() - let cursorLeft = componentNode.querySelector('.cursor').getBoundingClientRect().left - let line0Right = componentNode.querySelector('.line > span:last-child').getBoundingClientRect().right - expect(cursorLeft).toBeCloseTo(line0Right, 0) - }) - }) - - describe('when stylesheets change while the editor is hidden', function () { - afterEach(function () { - atom.themes.removeStylesheet('test') - }) - - it('does not re-measure character widths until the editor is shown again', async function () { - atom.config.set('editor.fontFamily', 'sans-serif') - wrapperNode.style.display = 'none' - atom.themes.applyStylesheet('test', '.syntax--function.syntax--js {\n font-weight: bold;\n}') - wrapperNode.style.display = '' - await conditionPromise(() => component.becameVisible.callCount > 0) - editor.setCursorBufferPosition([0, Infinity]) - runAnimationFrames() - let cursorLeft = componentNode.querySelector('.cursor').getBoundingClientRect().left - let line0Right = componentNode.querySelector('.line > span:last-child').getBoundingClientRect().right - expect(cursorLeft).toBeCloseTo(line0Right, 0) - }) - }) - }) - - describe('soft wrapping', function () { - beforeEach(function () { - editor.setSoftWrapped(true) - runAnimationFrames() - spyOn(component, 'measureDimensions').andCallThrough() - }) - - it('updates the wrap location when the editor is resized', function () { - let newHeight = 4 * editor.getLineHeightInPixels() + 'px' - expect(parseInt(newHeight)).toBeLessThan(wrapperNode.offsetHeight) - wrapperNode.style.height = newHeight - editor.update({autoHeight: false}) - component.measureDimensions() // Called by element resize detector - runAnimationFrames() - - expect(componentNode.querySelectorAll('.line')).toHaveLength(7) - let gutterWidth = componentNode.querySelector('.gutter').offsetWidth - componentNode.style.width = gutterWidth + 14 * charWidth + wrapperNode.getVerticalScrollbarWidth() + 'px' - - component.measureDimensions() // Called by element resize detector - runAnimationFrames() - expect(componentNode.querySelector('.line').textContent).toBe('var quicksort ') - }) - - it('accounts for the scroll view\'s padding when determining the wrap location', function () { - let scrollViewNode = componentNode.querySelector('.scroll-view') - scrollViewNode.style.paddingLeft = 20 + 'px' - componentNode.style.width = 30 * charWidth + 'px' - component.measureDimensions() // Called by element resize detector - runAnimationFrames() - expect(component.lineNodeForScreenRow(0).textContent).toBe('var quicksort = ') - }) - }) - - describe('default decorations', function () { - it('applies .cursor-line decorations for line numbers overlapping selections', function () { - editor.setCursorScreenPosition([4, 4]) - runAnimationFrames() - - expect(lineNumberHasClass(3, 'cursor-line')).toBe(false) - expect(lineNumberHasClass(4, 'cursor-line')).toBe(true) - expect(lineNumberHasClass(5, 'cursor-line')).toBe(false) - editor.setSelectedScreenRange([[3, 4], [4, 4]]) - runAnimationFrames() - - expect(lineNumberHasClass(3, 'cursor-line')).toBe(true) - expect(lineNumberHasClass(4, 'cursor-line')).toBe(true) - editor.setSelectedScreenRange([[3, 4], [4, 0]]) - runAnimationFrames() - - expect(lineNumberHasClass(3, 'cursor-line')).toBe(true) - expect(lineNumberHasClass(4, 'cursor-line')).toBe(false) - }) - - it('does not apply .cursor-line to the last line of a selection if it\'s empty', function () { - editor.setSelectedScreenRange([[3, 4], [5, 0]]) - runAnimationFrames() - expect(lineNumberHasClass(3, 'cursor-line')).toBe(true) - expect(lineNumberHasClass(4, 'cursor-line')).toBe(true) - expect(lineNumberHasClass(5, 'cursor-line')).toBe(false) - }) - - it('applies .cursor-line decorations for lines containing the cursor in non-empty selections', function () { - editor.setCursorScreenPosition([4, 4]) - runAnimationFrames() - - expect(lineHasClass(3, 'cursor-line')).toBe(false) - expect(lineHasClass(4, 'cursor-line')).toBe(true) - expect(lineHasClass(5, 'cursor-line')).toBe(false) - editor.setSelectedScreenRange([[3, 4], [4, 4]]) - runAnimationFrames() - - expect(lineHasClass(2, 'cursor-line')).toBe(false) - expect(lineHasClass(3, 'cursor-line')).toBe(false) - expect(lineHasClass(4, 'cursor-line')).toBe(false) - expect(lineHasClass(5, 'cursor-line')).toBe(false) - }) - - it('applies .cursor-line-no-selection to line numbers for rows containing the cursor when the selection is empty', function () { - editor.setCursorScreenPosition([4, 4]) - runAnimationFrames() - - expect(lineNumberHasClass(4, 'cursor-line-no-selection')).toBe(true) - editor.setSelectedScreenRange([[3, 4], [4, 4]]) - runAnimationFrames() - - expect(lineNumberHasClass(4, 'cursor-line-no-selection')).toBe(false) - }) - }) - - describe('height', function () { - describe('when autoHeight is true', function () { - it('assigns the editor\'s height to based on its contents', function () { - jasmine.attachToDOM(wrapperNode) - expect(editor.getAutoHeight()).toBe(true) - expect(wrapperNode.offsetHeight).toBe(editor.getLineHeightInPixels() * editor.getScreenLineCount()) - editor.insertText('\n\n\n') - runAnimationFrames() - expect(wrapperNode.offsetHeight).toBe(editor.getLineHeightInPixels() * editor.getScreenLineCount()) - }) - }) - - describe('when autoHeight is false', function () { - it('does not assign the height of the editor, instead allowing content to scroll', function () { - jasmine.attachToDOM(wrapperNode) - editor.update({autoHeight: false}) - wrapperNode.style.height = '200px' - expect(wrapperNode.offsetHeight).toBe(200) - editor.insertText('\n\n\n') - runAnimationFrames() - expect(wrapperNode.offsetHeight).toBe(200) - }) - }) - - describe('when autoHeight is not assigned on the editor', function () { - it('implicitly assigns autoHeight to true and emits a deprecation warning if the editor has its height assigned via an inline style', function () { - editor = new TextEditor() - element = editor.getElement() - element.setUpdatedSynchronously(false) - element.style.height = '200px' - - spyOn(Grim, 'deprecate') - jasmine.attachToDOM(element) - - expect(element.offsetHeight).toBe(200) - expect(element.querySelector('.editor-contents--private').offsetHeight).toBe(200) - expect(Grim.deprecate.callCount).toBe(1) - expect(Grim.deprecate.argsForCall[0][0]).toMatch(/inline style/) - }) - - it('implicitly assigns autoHeight to true and emits a deprecation warning if the editor has its height assigned via position absolute with an assigned top and bottom', function () { - editor = new TextEditor() - element = editor.getElement() - element.setUpdatedSynchronously(false) - parentElement = document.createElement('div') - parentElement.style.position = 'absolute' - parentElement.style.height = '200px' - element.style.position = 'absolute' - element.style.top = '0px' - element.style.bottom = '0px' - parentElement.appendChild(element) - - spyOn(Grim, 'deprecate') - - jasmine.attachToDOM(parentElement) - element.component.measureDimensions() - - expect(element.offsetHeight).toBe(200) - expect(element.querySelector('.editor-contents--private').offsetHeight).toBe(200) - expect(Grim.deprecate.callCount).toBe(1) - expect(Grim.deprecate.argsForCall[0][0]).toMatch(/absolute/) - }) - }) - - describe('when the wrapper view has an explicit height', function () { - it('does not assign a height on the component node', function () { - wrapperNode.style.height = '200px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - expect(componentNode.style.height).toBe('') - }) - }) - - describe('when the wrapper view does not have an explicit height', function () { - it('assigns a height on the component node based on the editor\'s content', function () { - expect(wrapperNode.style.height).toBe('') - expect(componentNode.style.height).toBe(editor.getScreenLineCount() * lineHeightInPixels + 'px') - }) - }) - }) - - describe('width', function () { - it('sizes the editor element according to the content width when auto width is true, or according to the container width otherwise', function () { - contentNode.style.width = '600px' - component.measureDimensions() - editor.setText("abcdefghi") - runAnimationFrames() - expect(wrapperNode.offsetWidth).toBe(contentNode.offsetWidth) - - editor.update({autoWidth: true}) - runAnimationFrames() - const editorWidth1 = wrapperNode.offsetWidth - expect(editorWidth1).toBeGreaterThan(0) - expect(editorWidth1).toBeLessThan(contentNode.offsetWidth) - - editor.setText("abcdefghijkl") - editor.update({autoWidth: true}) - runAnimationFrames() - const editorWidth2 = wrapperNode.offsetWidth - expect(editorWidth2).toBeGreaterThan(editorWidth1) - expect(editorWidth2).toBeLessThan(contentNode.offsetWidth) - - editor.update({autoWidth: false}) - runAnimationFrames() - expect(wrapperNode.offsetWidth).toBe(contentNode.offsetWidth) - }) - }) - - describe('when the "mini" property is true', function () { - beforeEach(function () { - editor.setMini(true) - runAnimationFrames() - }) - - it('does not render the gutter', function () { - expect(componentNode.querySelector('.gutter')).toBeNull() - }) - - it('adds the "mini" class to the wrapper view', function () { - expect(wrapperNode.classList.contains('mini')).toBe(true) - }) - - it('does not have an opaque background on lines', function () { - expect(component.linesComponent.getDomNode().getAttribute('style')).not.toContain('background-color') - }) - - it('does not render invisible characters', function () { - editor.update({ - showInvisibles: true, - invisibles: {eol: 'E'} - }) - expect(component.lineNodeForScreenRow(0).textContent).toBe('var quicksort = function () {') - }) - - it('does not assign an explicit line-height on the editor contents', function () { - expect(componentNode.style.lineHeight).toBe('') - }) - - it('does not apply cursor-line decorations', function () { - expect(component.lineNodeForScreenRow(0).classList.contains('cursor-line')).toBe(false) - }) - }) - - describe('when placholderText is specified', function () { - it('renders the placeholder text when the buffer is empty', function () { - editor.setPlaceholderText('Hello World') - expect(componentNode.querySelector('.placeholder-text')).toBeNull() - editor.setText('') - runAnimationFrames() - - expect(componentNode.querySelector('.placeholder-text').textContent).toBe('Hello World') - editor.setText('hey') - runAnimationFrames() - - expect(componentNode.querySelector('.placeholder-text')).toBeNull() - }) - }) - - describe('grammar data attributes', function () { - it('adds and updates the grammar data attribute based on the current grammar', function () { - expect(wrapperNode.dataset.grammar).toBe('source js') - editor.setGrammar(atom.grammars.nullGrammar) - expect(wrapperNode.dataset.grammar).toBe('text plain null-grammar') - }) - }) - - describe('encoding data attributes', function () { - it('adds and updates the encoding data attribute based on the current encoding', function () { - expect(wrapperNode.dataset.encoding).toBe('utf8') - editor.setEncoding('utf16le') - expect(wrapperNode.dataset.encoding).toBe('utf16le') - }) - }) - - describe('detaching and reattaching the editor (regression)', function () { - it('does not throw an exception', function () { - wrapperNode.remove() - jasmine.attachToDOM(wrapperNode) - atom.commands.dispatch(wrapperNode, 'core:move-right') - expect(editor.getCursorBufferPosition()).toEqual([0, 1]) - }) - }) - - describe('autoscroll', function () { - beforeEach(function () { - editor.setVerticalScrollMargin(2) - editor.setHorizontalScrollMargin(2) - component.setLineHeight('10px') - component.setFontSize(17) - component.measureDimensions() - runAnimationFrames() - - wrapperNode.style.width = 55 + component.getGutterWidth() + 'px' - wrapperNode.style.height = '55px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - component.presenter.setHorizontalScrollbarHeight(0) - component.presenter.setVerticalScrollbarWidth(0) - runAnimationFrames() - }) - - describe('when selecting buffer ranges', function () { - it('autoscrolls the selection if it is last unless the "autoscroll" option is false', function () { - expect(wrapperNode.getScrollTop()).toBe(0) - editor.setSelectedBufferRange([[5, 6], [6, 8]]) - runAnimationFrames() - - let right = wrapperNode.pixelPositionForBufferPosition([6, 8 + editor.getHorizontalScrollMargin()]).left - expect(wrapperNode.getScrollBottom()).toBe((7 + editor.getVerticalScrollMargin()) * 10) - expect(wrapperNode.getScrollRight()).toBeCloseTo(right, 0) - editor.setSelectedBufferRange([[0, 0], [0, 0]]) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - expect(wrapperNode.getScrollLeft()).toBe(0) - editor.setSelectedBufferRange([[6, 6], [6, 8]]) - runAnimationFrames() - - expect(wrapperNode.getScrollBottom()).toBe((7 + editor.getVerticalScrollMargin()) * 10) - expect(wrapperNode.getScrollRight()).toBeCloseTo(right, 0) - }) - }) - - describe('when adding selections for buffer ranges', function () { - it('autoscrolls to the added selection if needed', function () { - editor.addSelectionForBufferRange([[8, 10], [8, 15]]) - runAnimationFrames() - - let right = wrapperNode.pixelPositionForBufferPosition([8, 15]).left - expect(wrapperNode.getScrollBottom()).toBe((9 * 10) + (2 * 10)) - expect(wrapperNode.getScrollRight()).toBeCloseTo(right + 2 * 10, 0) - }) - }) - - describe('when selecting lines containing cursors', function () { - it('autoscrolls to the selection', function () { - editor.setCursorScreenPosition([5, 6]) - runAnimationFrames() - - wrapperNode.scrollToTop() - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.selectLinesContainingCursors() - runAnimationFrames() - - expect(wrapperNode.getScrollBottom()).toBe((7 + editor.getVerticalScrollMargin()) * 10) - }) - }) - - describe('when inserting text', function () { - describe('when there are multiple empty selections on different lines', function () { - it('autoscrolls to the last cursor', function () { - editor.setCursorScreenPosition([1, 2], { - autoscroll: false - }) - runAnimationFrames() - - editor.addCursorAtScreenPosition([10, 4], { - autoscroll: false - }) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.insertText('a') - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(75) - }) - }) - }) - - describe('when scrolled to cursor position', function () { - it('scrolls the last cursor into view, centering around the cursor if possible and the "center" option is not false', function () { - editor.setCursorScreenPosition([8, 8], { - autoscroll: false - }) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - expect(wrapperNode.getScrollLeft()).toBe(0) - editor.scrollToCursorPosition() - runAnimationFrames() - - let right = wrapperNode.pixelPositionForScreenPosition([8, 9 + editor.getHorizontalScrollMargin()]).left - expect(wrapperNode.getScrollTop()).toBe((8.8 * 10) - 30) - expect(wrapperNode.getScrollBottom()).toBe((8.3 * 10) + 30) - expect(wrapperNode.getScrollRight()).toBeCloseTo(right, 0) - wrapperNode.setScrollTop(0) - editor.scrollToCursorPosition({ - center: false - }) - expect(wrapperNode.getScrollTop()).toBe((7.8 - editor.getVerticalScrollMargin()) * 10) - expect(wrapperNode.getScrollBottom()).toBe((9.3 + editor.getVerticalScrollMargin()) * 10) - }) - }) - - describe('moving cursors', function () { - it('scrolls down when the last cursor gets closer than ::verticalScrollMargin to the bottom of the editor', function () { - expect(wrapperNode.getScrollTop()).toBe(0) - expect(wrapperNode.getScrollBottom()).toBe(5.5 * 10) - editor.setCursorScreenPosition([2, 0]) - runAnimationFrames() - - expect(wrapperNode.getScrollBottom()).toBe(5.5 * 10) - editor.moveDown() - runAnimationFrames() - - expect(wrapperNode.getScrollBottom()).toBe(6 * 10) - editor.moveDown() - runAnimationFrames() - - expect(wrapperNode.getScrollBottom()).toBe(7 * 10) - }) - - it('scrolls up when the last cursor gets closer than ::verticalScrollMargin to the top of the editor', function () { - editor.setCursorScreenPosition([11, 0]) - runAnimationFrames() - - wrapperNode.setScrollBottom(wrapperNode.getScrollHeight()) - runAnimationFrames() - - editor.moveUp() - runAnimationFrames() - - expect(wrapperNode.getScrollBottom()).toBe(wrapperNode.getScrollHeight()) - editor.moveUp() - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(7 * 10) - editor.moveUp() - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(6 * 10) - }) - - it('scrolls right when the last cursor gets closer than ::horizontalScrollMargin to the right of the editor', function () { - expect(wrapperNode.getScrollLeft()).toBe(0) - expect(wrapperNode.getScrollRight()).toBe(5.5 * 10) - editor.setCursorScreenPosition([0, 2]) - runAnimationFrames() - - expect(wrapperNode.getScrollRight()).toBe(5.5 * 10) - editor.moveRight() - runAnimationFrames() - - let margin = component.presenter.getHorizontalScrollMarginInPixels() - let right = wrapperNode.pixelPositionForScreenPosition([0, 4]).left + margin - expect(wrapperNode.getScrollRight()).toBeCloseTo(right, 0) - editor.moveRight() - runAnimationFrames() - - right = wrapperNode.pixelPositionForScreenPosition([0, 5]).left + margin - expect(wrapperNode.getScrollRight()).toBeCloseTo(right, 0) - }) - - it('scrolls left when the last cursor gets closer than ::horizontalScrollMargin to the left of the editor', function () { - wrapperNode.setScrollRight(wrapperNode.getScrollWidth()) - runAnimationFrames() - - expect(wrapperNode.getScrollRight()).toBe(wrapperNode.getScrollWidth()) - editor.setCursorScreenPosition([6, 62], { - autoscroll: false - }) - runAnimationFrames() - - editor.moveLeft() - runAnimationFrames() - - let margin = component.presenter.getHorizontalScrollMarginInPixels() - let left = wrapperNode.pixelPositionForScreenPosition([6, 61]).left - margin - expect(wrapperNode.getScrollLeft()).toBeCloseTo(left, 0) - editor.moveLeft() - runAnimationFrames() - - left = wrapperNode.pixelPositionForScreenPosition([6, 60]).left - margin - expect(wrapperNode.getScrollLeft()).toBeCloseTo(left, 0) - }) - - it('scrolls down when inserting lines makes the document longer than the editor\'s height', function () { - editor.setCursorScreenPosition([13, Infinity]) - editor.insertNewline() - runAnimationFrames() - - expect(wrapperNode.getScrollBottom()).toBe(14 * 10) - editor.insertNewline() - runAnimationFrames() - - expect(wrapperNode.getScrollBottom()).toBe(15 * 10) - }) - - it('autoscrolls to the cursor when it moves due to undo', function () { - editor.insertText('abc') - wrapperNode.setScrollTop(Infinity) - runAnimationFrames() - - editor.undo() - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - }) - - it('does not scroll when the cursor moves into the visible area', function () { - editor.setCursorBufferPosition([0, 0]) - runAnimationFrames() - - wrapperNode.setScrollTop(40) - runAnimationFrames() - - editor.setCursorBufferPosition([6, 0]) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(40) - }) - - it('honors the autoscroll option on cursor and selection manipulation methods', function () { - expect(wrapperNode.getScrollTop()).toBe(0) - editor.addCursorAtScreenPosition([11, 11], {autoscroll: false}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.addCursorAtBufferPosition([11, 11], {autoscroll: false}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.setCursorScreenPosition([11, 11], {autoscroll: false}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.setCursorBufferPosition([11, 11], {autoscroll: false}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.addSelectionForBufferRange([[11, 11], [11, 11]], {autoscroll: false}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.addSelectionForScreenRange([[11, 11], [11, 12]], {autoscroll: false}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.setSelectedBufferRange([[11, 0], [11, 1]], {autoscroll: false}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.setSelectedScreenRange([[11, 0], [11, 6]], {autoscroll: false}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.clearSelections({autoscroll: false}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.addSelectionForScreenRange([[0, 0], [0, 4]]) - runAnimationFrames() - - editor.getCursors()[0].setScreenPosition([11, 11], {autoscroll: true}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBeGreaterThan(0) - editor.getCursors()[0].setBufferPosition([0, 0], {autoscroll: true}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - editor.getSelections()[0].setScreenRange([[11, 0], [11, 4]], {autoscroll: true}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBeGreaterThan(0) - editor.getSelections()[0].setBufferRange([[0, 0], [0, 4]], {autoscroll: true}) - runAnimationFrames() - - expect(wrapperNode.getScrollTop()).toBe(0) - }) - }) - }) - - describe('::getVisibleRowRange()', function () { - beforeEach(function () { - wrapperNode.style.height = lineHeightInPixels * 8 + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - }) - - it('returns the first and the last visible rows', function () { - component.setScrollTop(0) - runAnimationFrames() - expect(component.getVisibleRowRange()).toEqual([0, 9]) - }) - - it('ends at last buffer row even if there\'s more space available', function () { - wrapperNode.style.height = lineHeightInPixels * 13 + 'px' - editor.update({autoHeight: false}) - component.measureDimensions() - runAnimationFrames() - - component.setScrollTop(60) - runAnimationFrames() - - expect(component.getVisibleRowRange()).toEqual([0, 13]) - }) - }) - - describe('::pixelPositionForScreenPosition()', () => { - it('returns the correct horizontal position, even if it is on a row that has not yet been rendered (regression)', () => { - editor.setTextInBufferRange([[5, 0], [6, 0]], 'hello world\n') - expect(wrapperNode.pixelPositionForScreenPosition([5, Infinity]).left).toBeGreaterThan(0) - }) - }) - - describe('middle mouse paste on Linux', function () { - let originalPlatform - - beforeEach(function () { - originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - value: 'linux' - }) - }) - - afterEach(function () { - Object.defineProperty(process, 'platform', { - value: originalPlatform - }) - }) - - it('pastes the previously selected text at the clicked location', async function () { - let clipboardWrittenTo = false - spyOn(require('electron').ipcRenderer, 'send').andCallFake(function (eventName, selectedText) { - if (eventName === 'write-text-to-selection-clipboard') { - require('../src/safe-clipboard').writeText(selectedText, 'selection') - clipboardWrittenTo = true - } - }) - atom.clipboard.write('') - component.trackSelectionClipboard() - editor.setSelectedBufferRange([[1, 6], [1, 10]]) - advanceClock(0) - - componentNode.querySelector('.scroll-view').dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([10, 0]), { - button: 1 - })) - componentNode.querySelector('.scroll-view').dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenPosition([10, 0]), { - which: 2 - })) - expect(atom.clipboard.read()).toBe('sort') - expect(editor.lineTextForBufferRow(10)).toBe('sort') - }) - }) - - function buildMouseEvent (type, ...propertiesObjects) { - let properties = extend({ - bubbles: true, - cancelable: true - }, ...propertiesObjects) - - if (properties.detail == null) { - properties.detail = 1 - } - - let event = new MouseEvent(type, properties) - if (properties.which != null) { - Object.defineProperty(event, 'which', { - get: function () { - return properties.which - } - }) - } - if (properties.target != null) { - Object.defineProperty(event, 'target', { - get: function () { - return properties.target - } - }) - Object.defineProperty(event, 'srcObject', { - get: function () { - return properties.target - } - }) - } - return event - } - - function clientCoordinatesForScreenPosition (screenPosition) { - let clientX, clientY, positionOffset, scrollViewClientRect - positionOffset = wrapperNode.pixelPositionForScreenPosition(screenPosition) - scrollViewClientRect = componentNode.querySelector('.scroll-view').getBoundingClientRect() - clientX = scrollViewClientRect.left + positionOffset.left - wrapperNode.getScrollLeft() - clientY = scrollViewClientRect.top + positionOffset.top - wrapperNode.getScrollTop() - return { - clientX: clientX, - clientY: clientY - } - } - - function clientCoordinatesForScreenRowInGutter (screenRow) { - let clientX, clientY, gutterClientRect, positionOffset - positionOffset = wrapperNode.pixelPositionForScreenPosition([screenRow, Infinity]) - gutterClientRect = componentNode.querySelector('.gutter').getBoundingClientRect() - clientX = gutterClientRect.left + positionOffset.left - wrapperNode.getScrollLeft() - clientY = gutterClientRect.top + positionOffset.top - wrapperNode.getScrollTop() - return { - clientX: clientX, - clientY: clientY - } - } - - function lineAndLineNumberHaveClass (screenRow, klass) { - return lineHasClass(screenRow, klass) && lineNumberHasClass(screenRow, klass) - } - - function lineNumberHasClass (screenRow, klass) { - return component.lineNumberNodeForScreenRow(screenRow).classList.contains(klass) - } - - function lineNumberForBufferRowHasClass (bufferRow, klass) { - let screenRow - screenRow = editor.screenRowForBufferRow(bufferRow) - return component.lineNumberNodeForScreenRow(screenRow).classList.contains(klass) - } - - function lineHasClass (screenRow, klass) { - return component.lineNodeForScreenRow(screenRow).classList.contains(klass) - } - - function getLeafNodes (node) { - if (node.children.length > 0) { - return flatten(toArray(node.children).map(getLeafNodes)) - } else { - return [node] - } - } - - function conditionPromise (condition) { - let timeoutError = new Error("Timed out waiting on condition") - Error.captureStackTrace(timeoutError, conditionPromise) - - return new Promise(function (resolve, reject) { - let interval = window.setInterval.originalValue.apply(window, [function () { - if (condition()) { - window.clearInterval(interval) - window.clearTimeout(timeout) - resolve() - } - }, 100]) - let timeout = window.setTimeout.originalValue.apply(window, [function () { - window.clearInterval(interval) - reject(timeoutError) - }, 5000]) - }) - } - - function decorationsUpdatedPromise(editor) { - return new Promise(function (resolve) { - let disposable = editor.onDidUpdateDecorations(function () { - disposable.dispose() - resolve() - }) - }) - } -}) diff --git a/spec/text-editor-presenter-spec.coffee b/spec/text-editor-presenter-spec.coffee deleted file mode 100644 index 2b382b9383d..00000000000 --- a/spec/text-editor-presenter-spec.coffee +++ /dev/null @@ -1,3901 +0,0 @@ -_ = require 'underscore-plus' -randomWords = require 'random-words' -TextBuffer = require 'text-buffer' -{Point, Range} = TextBuffer -TextEditor = require '../src/text-editor' -TextEditorPresenter = require '../src/text-editor-presenter' -FakeLinesYardstick = require './fake-lines-yardstick' -LineTopIndex = require 'line-top-index' - -xdescribe "TextEditorPresenter", -> - # These `describe` and `it` blocks mirror the structure of the ::state object. - # Please maintain this structure when adding specs for new state fields. - describe "::get(Pre|Post)MeasurementState()", -> - [buffer, editor] = [] - - beforeEach -> - # These *should* be mocked in the spec helper, but changing that now would break packages :-( - spyOn(window, "setInterval").andCallFake window.fakeSetInterval - spyOn(window, "clearInterval").andCallFake window.fakeClearInterval - - buffer = new TextBuffer(filePath: require.resolve('./fixtures/sample.js')) - editor = new TextEditor({buffer}) - waitsForPromise -> buffer.load() - - afterEach -> - editor.destroy() - buffer.destroy() - - getState = (presenter) -> - presenter.getPreMeasurementState() - presenter.getPostMeasurementState() - - addBlockDecorationBeforeScreenRow = (screenRow, item) -> - editor.decorateMarker( - editor.markScreenPosition([screenRow, 0], invalidate: "never"), - type: "block", item: item, position: "before" - ) - - addBlockDecorationAfterScreenRow = (screenRow, item) -> - editor.decorateMarker( - editor.markScreenPosition([screenRow, 0], invalidate: "never"), - type: "block", item: item, position: "after" - ) - - buildPresenterWithoutMeasurements = (params={}) -> - lineTopIndex = new LineTopIndex({ - defaultLineHeight: editor.getLineHeightInPixels() - }) - _.defaults params, - model: editor - contentFrameWidth: 500 - lineTopIndex: lineTopIndex - presenter = new TextEditorPresenter(params) - presenter.setLinesYardstick(new FakeLinesYardstick(editor, lineTopIndex)) - presenter - - buildPresenter = (params={}) -> - presenter = buildPresenterWithoutMeasurements(params) - presenter.setScrollTop(params.scrollTop) if params.scrollTop? - presenter.setScrollLeft(params.scrollLeft) if params.scrollLeft? - presenter.setExplicitHeight(params.explicitHeight ? 130) - presenter.setWindowSize(params.windowWidth ? 500, params.windowHeight ? 130) - presenter.setBoundingClientRect(params.boundingClientRect ? { - left: 0 - top: 0 - width: 500 - height: 130 - }) - presenter.setGutterWidth(params.gutterWidth ? 0) - presenter.setLineHeight(params.lineHeight ? 10) - presenter.setBaseCharacterWidth(params.baseCharacterWidth ? 10) - presenter.setHorizontalScrollbarHeight(params.horizontalScrollbarHeight ? 10) - presenter.setVerticalScrollbarWidth(params.verticalScrollbarWidth ? 10) - presenter - - expectValues = (actual, expected) -> - for key, value of expected - expect(actual[key]).toEqual value - - expectStateUpdatedToBe = (value, presenter, fn) -> - updatedState = false - disposable = presenter.onDidUpdateState -> - updatedState = true - disposable.dispose() - fn() - expect(updatedState).toBe(value) - - expectStateUpdate = (presenter, fn) -> expectStateUpdatedToBe(true, presenter, fn) - - expectNoStateUpdate = (presenter, fn) -> expectStateUpdatedToBe(false, presenter, fn) - - waitsForStateToUpdate = (presenter, fn) -> - line = new Error().stack.split('\n')[2].split(':')[1] - - waitsFor "presenter state to update at line #{line}", 1000, (done) -> - disposable = presenter.onDidUpdateState -> - disposable.dispose() - process.nextTick(done) - fn?() - - tiledContentContract = (stateFn) -> - it "contains states for tiles that are visible on screen", -> - presenter = buildPresenter(explicitHeight: 6, scrollTop: 0, lineHeight: 1, tileSize: 2) - - expectValues stateFn(presenter).tiles[0], { - top: 0 - } - expectValues stateFn(presenter).tiles[2], { - top: 2 - } - expectValues stateFn(presenter).tiles[4], { - top: 4 - } - expectValues stateFn(presenter).tiles[6], { - top: 6 - } - - expect(stateFn(presenter).tiles[8]).toBeUndefined() - - expectStateUpdate presenter, -> presenter.setScrollTop(3) - - expect(stateFn(presenter).tiles[0]).toBeUndefined() - - expectValues stateFn(presenter).tiles[2], { - top: -1 - } - expectValues stateFn(presenter).tiles[4], { - top: 1 - } - expectValues stateFn(presenter).tiles[6], { - top: 3 - } - expectValues stateFn(presenter).tiles[8], { - top: 5 - } - expectValues stateFn(presenter).tiles[10], { - top: 7 - } - - expect(stateFn(presenter).tiles[12]).toBeUndefined() - - it "includes state for tiles containing screen rows to measure", -> - presenter = buildPresenter(explicitHeight: 6, scrollTop: 0, lineHeight: 1, tileSize: 2) - presenter.setScreenRowsToMeasure([10, 12]) - - expect(stateFn(presenter).tiles[0]).toBeDefined() - expect(stateFn(presenter).tiles[2]).toBeDefined() - expect(stateFn(presenter).tiles[4]).toBeDefined() - expect(stateFn(presenter).tiles[6]).toBeDefined() - expect(stateFn(presenter).tiles[8]).toBeUndefined() - expect(stateFn(presenter).tiles[10]).toBeDefined() - expect(stateFn(presenter).tiles[12]).toBeDefined() - - # clearing additional rows won't trigger a state update - expectNoStateUpdate presenter, -> presenter.clearScreenRowsToMeasure() - - # when another change triggers a state update we remove useless lines - expectStateUpdate presenter, -> presenter.setScrollTop(1) - - expect(stateFn(presenter).tiles[0]).toBeDefined() - expect(stateFn(presenter).tiles[2]).toBeDefined() - expect(stateFn(presenter).tiles[4]).toBeDefined() - expect(stateFn(presenter).tiles[6]).toBeDefined() - expect(stateFn(presenter).tiles[8]).toBeDefined() - expect(stateFn(presenter).tiles[10]).toBeUndefined() - expect(stateFn(presenter).tiles[12]).toBeUndefined() - - describe "when there are block decorations", -> - it "computes each tile's height and scrollTop based on block decorations' height", -> - presenter = buildPresenter(explicitHeight: 120, scrollTop: 0, lineHeight: 10, tileSize: 2) - - blockDecoration1 = addBlockDecorationBeforeScreenRow(0) - blockDecoration2 = addBlockDecorationBeforeScreenRow(3) - blockDecoration3 = addBlockDecorationBeforeScreenRow(5) - blockDecoration4 = addBlockDecorationAfterScreenRow(5) - presenter.setBlockDecorationDimensions(blockDecoration1, 0, 1) - presenter.setBlockDecorationDimensions(blockDecoration2, 0, 30) - presenter.setBlockDecorationDimensions(blockDecoration3, 0, 40) - presenter.setBlockDecorationDimensions(blockDecoration4, 0, 50) - - expect(stateFn(presenter).tiles[0].height).toBe(2 * 10 + 1) - expect(stateFn(presenter).tiles[0].top).toBe(0 * 10) - expect(stateFn(presenter).tiles[2].height).toBe(2 * 10 + 30) - expect(stateFn(presenter).tiles[2].top).toBe(2 * 10 + 1) - expect(stateFn(presenter).tiles[4].height).toBe(2 * 10 + 40 + 50) - expect(stateFn(presenter).tiles[4].top).toBe(4 * 10 + 1 + 30) - expect(stateFn(presenter).tiles[6].height).toBe(2 * 10) - expect(stateFn(presenter).tiles[6].top).toBe(6 * 10 + 1 + 30 + 40 + 50) - expect(stateFn(presenter).tiles[8]).toBeUndefined() - - presenter.setScrollTop(21) - - expect(stateFn(presenter).tiles[0]).toBeUndefined() - expect(stateFn(presenter).tiles[2].height).toBe(2 * 10 + 30) - expect(stateFn(presenter).tiles[2].top).toBe(2 * 10 + 1 - 21) - expect(stateFn(presenter).tiles[4].height).toBe(2 * 10 + 40 + 50) - expect(stateFn(presenter).tiles[4].top).toBe(4 * 10 + 1 + 30 - 21) - expect(stateFn(presenter).tiles[6].height).toBe(2 * 10) - expect(stateFn(presenter).tiles[6].top).toBe(6 * 10 + 1 + 30 + 40 + 50 - 21) - expect(stateFn(presenter).tiles[8]).toBeUndefined() - - blockDecoration3.getMarker().setHeadScreenPosition([6, 0]) - - expect(stateFn(presenter).tiles[0]).toBeUndefined() - expect(stateFn(presenter).tiles[2].height).toBe(2 * 10 + 30) - expect(stateFn(presenter).tiles[2].top).toBe(2 * 10 + 1 - 21) - expect(stateFn(presenter).tiles[4].height).toBe(2 * 10 + 50) - expect(stateFn(presenter).tiles[4].top).toBe(4 * 10 + 1 + 30 - 21) - expect(stateFn(presenter).tiles[6].height).toBe(2 * 10 + 40) - expect(stateFn(presenter).tiles[6].top).toBe(6 * 10 + 1 + 30 + 50 - 21) - expect(stateFn(presenter).tiles[8]).toBeUndefined() - - it "works correctly when soft wrapping is enabled", -> - blockDecoration1 = addBlockDecorationBeforeScreenRow(0) - blockDecoration2 = addBlockDecorationBeforeScreenRow(4) - blockDecoration3 = addBlockDecorationBeforeScreenRow(8) - - presenter = buildPresenter(explicitHeight: 330, lineHeight: 10, tileSize: 2, baseCharacterWidth: 5) - - presenter.setBlockDecorationDimensions(blockDecoration1, 0, 10) - presenter.setBlockDecorationDimensions(blockDecoration2, 0, 20) - presenter.setBlockDecorationDimensions(blockDecoration3, 0, 30) - - expect(stateFn(presenter).tiles[0].top).toBe(0 * 10) - expect(stateFn(presenter).tiles[2].top).toBe(2 * 10 + 10) - expect(stateFn(presenter).tiles[4].top).toBe(4 * 10 + 10) - expect(stateFn(presenter).tiles[6].top).toBe(6 * 10 + 10 + 20) - expect(stateFn(presenter).tiles[8].top).toBe(8 * 10 + 10 + 20) - expect(stateFn(presenter).tiles[10].top).toBe(10 * 10 + 10 + 20 + 30) - expect(stateFn(presenter).tiles[12].top).toBe(12 * 10 + 10 + 20 + 30) - - editor.update({softWrapped: true}) - presenter.setContentFrameWidth(5 * 25) - - expect(stateFn(presenter).tiles[0].top).toBe(0 * 10) - expect(stateFn(presenter).tiles[2].top).toBe(2 * 10 + 10) - expect(stateFn(presenter).tiles[4].top).toBe(4 * 10 + 10) - expect(stateFn(presenter).tiles[6].top).toBe(6 * 10 + 10) - expect(stateFn(presenter).tiles[8].top).toBe(8 * 10 + 10) - expect(stateFn(presenter).tiles[10].top).toBe(10 * 10 + 10) - expect(stateFn(presenter).tiles[12].top).toBe(12 * 10 + 10 + 20) - expect(stateFn(presenter).tiles[14].top).toBe(14 * 10 + 10 + 20) - expect(stateFn(presenter).tiles[16].top).toBe(16 * 10 + 10 + 20) - expect(stateFn(presenter).tiles[18].top).toBe(18 * 10 + 10 + 20) - expect(stateFn(presenter).tiles[20].top).toBe(20 * 10 + 10 + 20) - expect(stateFn(presenter).tiles[22].top).toBe(22 * 10 + 10 + 20 + 30) - expect(stateFn(presenter).tiles[24].top).toBe(24 * 10 + 10 + 20 + 30) - expect(stateFn(presenter).tiles[26].top).toBe(26 * 10 + 10 + 20 + 30) - expect(stateFn(presenter).tiles[28].top).toBe(28 * 10 + 10 + 20 + 30) - - editor.update({softWrapped: false}) - - expect(stateFn(presenter).tiles[0].top).toBe(0 * 10) - expect(stateFn(presenter).tiles[2].top).toBe(2 * 10 + 10) - expect(stateFn(presenter).tiles[4].top).toBe(4 * 10 + 10) - expect(stateFn(presenter).tiles[6].top).toBe(6 * 10 + 10 + 20) - expect(stateFn(presenter).tiles[8].top).toBe(8 * 10 + 10 + 20) - expect(stateFn(presenter).tiles[10].top).toBe(10 * 10 + 10 + 20 + 30) - expect(stateFn(presenter).tiles[12].top).toBe(12 * 10 + 10 + 20 + 30) - - it "includes state for all tiles if no external ::explicitHeight is assigned", -> - presenter = buildPresenter(explicitHeight: null, tileSize: 2) - expect(stateFn(presenter).tiles[0]).toBeDefined() - expect(stateFn(presenter).tiles[12]).toBeDefined() - - it "is empty until all of the required measurements are assigned", -> - presenter = buildPresenterWithoutMeasurements() - expect(stateFn(presenter).tiles).toEqual({}) - - presenter.setExplicitHeight(25) - expect(stateFn(presenter).tiles).toEqual({}) - - # Sets scroll row from model's logical position - presenter.setLineHeight(10) - expect(stateFn(presenter).tiles).not.toEqual({}) - - it "updates when ::scrollTop changes", -> - presenter = buildPresenter(explicitHeight: 6, scrollTop: 0, lineHeight: 1, tileSize: 2) - - expect(stateFn(presenter).tiles[0]).toBeDefined() - expect(stateFn(presenter).tiles[2]).toBeDefined() - expect(stateFn(presenter).tiles[4]).toBeDefined() - expect(stateFn(presenter).tiles[6]).toBeDefined() - expect(stateFn(presenter).tiles[8]).toBeUndefined() - - expectStateUpdate presenter, -> presenter.setScrollTop(2) - - expect(stateFn(presenter).tiles[0]).toBeUndefined() - expect(stateFn(presenter).tiles[2]).toBeDefined() - expect(stateFn(presenter).tiles[4]).toBeDefined() - expect(stateFn(presenter).tiles[6]).toBeDefined() - expect(stateFn(presenter).tiles[8]).toBeDefined() - expect(stateFn(presenter).tiles[10]).toBeUndefined() - - it "updates when ::explicitHeight changes", -> - presenter = buildPresenter(explicitHeight: 6, scrollTop: 0, lineHeight: 1, tileSize: 2) - - expect(stateFn(presenter).tiles[0]).toBeDefined() - expect(stateFn(presenter).tiles[2]).toBeDefined() - expect(stateFn(presenter).tiles[4]).toBeDefined() - expect(stateFn(presenter).tiles[6]).toBeDefined() - expect(stateFn(presenter).tiles[8]).toBeUndefined() - - expectStateUpdate presenter, -> presenter.setExplicitHeight(8) - - expect(stateFn(presenter).tiles[0]).toBeDefined() - expect(stateFn(presenter).tiles[2]).toBeDefined() - expect(stateFn(presenter).tiles[4]).toBeDefined() - expect(stateFn(presenter).tiles[6]).toBeDefined() - expect(stateFn(presenter).tiles[8]).toBeDefined() - expect(stateFn(presenter).tiles[10]).toBeUndefined() - - it "updates when ::lineHeight changes", -> - presenter = buildPresenter(explicitHeight: 6, scrollTop: 0, lineHeight: 1, tileSize: 2) - - expect(stateFn(presenter).tiles[0]).toBeDefined() - expect(stateFn(presenter).tiles[2]).toBeDefined() - expect(stateFn(presenter).tiles[4]).toBeDefined() - expect(stateFn(presenter).tiles[6]).toBeDefined() - expect(stateFn(presenter).tiles[8]).toBeUndefined() - - expectStateUpdate presenter, -> presenter.setLineHeight(4) - - expect(stateFn(presenter).tiles[0]).toBeDefined() - expect(stateFn(presenter).tiles[2]).toBeDefined() - expect(stateFn(presenter).tiles[4]).toBeUndefined() - expect(stateFn(presenter).tiles[6]).toBeDefined() - expect(stateFn(presenter).tiles[8]).toBeUndefined() - - it "does not remove out-of-view tiles corresponding to ::mouseWheelScreenRow until ::stoppedScrollingDelay elapses", -> - presenter = buildPresenter(explicitHeight: 6, scrollTop: 0, lineHeight: 1, tileSize: 2, stoppedScrollingDelay: 200) - - expect(stateFn(presenter).tiles[0]).toBeDefined() - expect(stateFn(presenter).tiles[6]).toBeDefined() - expect(stateFn(presenter).tiles[8]).toBeUndefined() - - presenter.setMouseWheelScreenRow(0) - expectStateUpdate presenter, -> presenter.setScrollTop(4) - - expect(stateFn(presenter).tiles[0]).toBeDefined() - expect(stateFn(presenter).tiles[2]).toBeUndefined() - expect(stateFn(presenter).tiles[4]).toBeDefined() - expect(stateFn(presenter).tiles[12]).toBeUndefined() - - expectStateUpdate presenter, -> advanceClock(200) - - expect(stateFn(presenter).tiles[0]).toBeUndefined() - expect(stateFn(presenter).tiles[2]).toBeUndefined() - expect(stateFn(presenter).tiles[4]).toBeDefined() - expect(stateFn(presenter).tiles[12]).toBeUndefined() - - - # should clear ::mouseWheelScreenRow after stoppedScrollingDelay elapses even if we don't scroll first - presenter.setMouseWheelScreenRow(4) - advanceClock(200) - expectStateUpdate presenter, -> presenter.setScrollTop(6) - expect(stateFn(presenter).tiles[4]).toBeUndefined() - - it "does not preserve deleted on-screen tiles even if they correspond to ::mouseWheelScreenRow", -> - presenter = buildPresenter(explicitHeight: 6, scrollTop: 0, lineHeight: 1, tileSize: 2, stoppedScrollingDelay: 200) - - presenter.setMouseWheelScreenRow(2) - - expectStateUpdate presenter, -> editor.setText("") - - expect(stateFn(presenter).tiles[2]).toBeUndefined() - expect(stateFn(presenter).tiles[0]).toBeDefined() - - describe "during state retrieval", -> - it "does not trigger onDidUpdateState events", -> - presenter = buildPresenter() - expectNoStateUpdate presenter, -> getState(presenter) - - describe ".horizontalScrollbar", -> - describe ".visible", -> - it "is true if the scrollWidth exceeds the computed client width", -> - presenter = buildPresenter - explicitHeight: editor.getLineCount() * 10 - contentFrameWidth: editor.getMaxScreenLineLength() * 10 + 1 - baseCharacterWidth: 10 - lineHeight: 10 - horizontalScrollbarHeight: 10 - verticalScrollbarWidth: 10 - - expect(getState(presenter).horizontalScrollbar.visible).toBe false - - # ::contentFrameWidth itself is smaller than scrollWidth - presenter.setContentFrameWidth(editor.getMaxScreenLineLength() * 10) - expect(getState(presenter).horizontalScrollbar.visible).toBe true - - # restore... - presenter.setContentFrameWidth(editor.getMaxScreenLineLength() * 10 + 1) - expect(getState(presenter).horizontalScrollbar.visible).toBe false - - # visible vertical scrollbar makes the clientWidth smaller than the scrollWidth - presenter.setExplicitHeight((editor.getLineCount() * 10) - 1) - expect(getState(presenter).horizontalScrollbar.visible).toBe true - - it "is false if the editor is mini", -> - presenter = buildPresenter - explicitHeight: editor.getLineCount() * 10 - contentFrameWidth: editor.getMaxScreenLineLength() * 10 - 10 - baseCharacterWidth: 10 - - expect(getState(presenter).horizontalScrollbar.visible).toBe true - editor.setMini(true) - expect(getState(presenter).horizontalScrollbar.visible).toBe false - editor.setMini(false) - expect(getState(presenter).horizontalScrollbar.visible).toBe true - - it "is false when `editor.autoWidth` is true", -> - editor.update({autoWidth: true}) - presenter = buildPresenter(explicitHeight: 10, contentFrameWidth: 30, verticalScrollbarWidth: 7, baseCharacterWidth: 10) - getState(presenter) # trigger a state update to store state in the presenter - editor.setText('abcdefghijklm') - expect(getState(presenter).horizontalScrollbar.visible).toBe(false) - - describe ".height", -> - it "tracks the value of ::horizontalScrollbarHeight", -> - presenter = buildPresenter(horizontalScrollbarHeight: 10) - expect(getState(presenter).horizontalScrollbar.height).toBe 10 - expectStateUpdate presenter, -> presenter.setHorizontalScrollbarHeight(20) - expect(getState(presenter).horizontalScrollbar.height).toBe 20 - - describe ".right", -> - it "is ::verticalScrollbarWidth if the vertical scrollbar is visible and 0 otherwise", -> - presenter = buildPresenter - explicitHeight: editor.getLineCount() * 10 + 50 - contentFrameWidth: editor.getMaxScreenLineLength() * 10 - baseCharacterWidth: 10 - lineHeight: 10 - horizontalScrollbarHeight: 10 - verticalScrollbarWidth: 10 - - expect(getState(presenter).horizontalScrollbar.right).toBe 0 - presenter.setExplicitHeight((editor.getLineCount() * 10) - 1) - expect(getState(presenter).horizontalScrollbar.right).toBe 10 - - describe ".scrollWidth", -> - it "is initialized as the max of the ::contentFrameWidth and the width of the longest line", -> - maxLineLength = editor.getMaxScreenLineLength() - - presenter = buildPresenter(contentFrameWidth: 50, baseCharacterWidth: 10) - expect(getState(presenter).horizontalScrollbar.scrollWidth).toBe 10 * maxLineLength + 1 - - presenter = buildPresenter(contentFrameWidth: 10 * maxLineLength + 20, baseCharacterWidth: 10) - expect(getState(presenter).horizontalScrollbar.scrollWidth).toBe 10 * maxLineLength + 20 - - it "updates when the ::contentFrameWidth changes", -> - maxLineLength = editor.getMaxScreenLineLength() - presenter = buildPresenter(contentFrameWidth: 50, baseCharacterWidth: 10) - - expect(getState(presenter).horizontalScrollbar.scrollWidth).toBe 10 * maxLineLength + 1 - expectStateUpdate presenter, -> presenter.setContentFrameWidth(10 * maxLineLength + 20) - expect(getState(presenter).horizontalScrollbar.scrollWidth).toBe 10 * maxLineLength + 20 - - it "updates when character widths change", -> - waitsForPromise -> atom.packages.activatePackage('language-javascript') - - runs -> - editor.setGrammar(atom.grammars.grammarForScopeName('source.js')) - maxLineLength = editor.getMaxScreenLineLength() - presenter = buildPresenter(contentFrameWidth: 50, baseCharacterWidth: 10) - - expect(getState(presenter).horizontalScrollbar.scrollWidth).toBe 10 * maxLineLength + 1 - expectStateUpdate presenter, -> - presenter.getLinesYardstick().setScopedCharacterWidth(['syntax--source.syntax--js', 'syntax--meta.syntax--method-call.syntax--js', 'syntax--support.syntax--function.syntax--js'], 'p', 20) - presenter.measurementsChanged() - expect(getState(presenter).horizontalScrollbar.scrollWidth).toBe (10 * (maxLineLength - 2)) + (20 * 2) + 1 # 2 of the characters are 20px wide now instead of 10px wide - - it "updates when ::softWrapped changes on the editor", -> - presenter = buildPresenter(contentFrameWidth: 470, baseCharacterWidth: 10) - expect(getState(presenter).horizontalScrollbar.scrollWidth).toBe 10 * editor.getMaxScreenLineLength() + 1 - expectStateUpdate presenter, -> editor.update({softWrapped: true}) - expect(getState(presenter).horizontalScrollbar.scrollWidth).toBe presenter.clientWidth - expectStateUpdate presenter, -> editor.update({softWrapped: false}) - expect(getState(presenter).horizontalScrollbar.scrollWidth).toBe 10 * editor.getMaxScreenLineLength() + 1 - - it "updates when the longest line changes", -> - presenter = buildPresenter(contentFrameWidth: 50, baseCharacterWidth: 10) - - expect(getState(presenter).horizontalScrollbar.scrollWidth).toBe 10 * editor.getMaxScreenLineLength() + 1 - - expectStateUpdate presenter, -> editor.setCursorBufferPosition([editor.getLongestScreenRow(), 0]) - expectStateUpdate presenter, -> editor.insertText('xyz') - - expect(getState(presenter).horizontalScrollbar.scrollWidth).toBe 10 * editor.getMaxScreenLineLength() + 1 - - describe ".scrollLeft", -> - it "tracks the value of ::scrollLeft", -> - presenter = buildPresenter(scrollLeft: 10, verticalScrollbarWidth: 10, contentFrameWidth: 500) - expect(getState(presenter).horizontalScrollbar.scrollLeft).toBe 10 - expectStateUpdate presenter, -> presenter.setScrollLeft(50) - expect(getState(presenter).horizontalScrollbar.scrollLeft).toBe 50 - - it "never exceeds the computed scrollWidth minus the computed clientWidth", -> - presenter = buildPresenter(scrollLeft: 10, verticalScrollbarWidth: 10, explicitHeight: 100, contentFrameWidth: 500) - expectStateUpdate presenter, -> presenter.setScrollLeft(300) - expect(getState(presenter).horizontalScrollbar.scrollLeft).toBe presenter.scrollWidth - presenter.clientWidth - - expectStateUpdate presenter, -> presenter.setContentFrameWidth(600) - expect(getState(presenter).horizontalScrollbar.scrollLeft).toBe presenter.scrollWidth - presenter.clientWidth - - expectStateUpdate presenter, -> presenter.setVerticalScrollbarWidth(5) - expect(getState(presenter).horizontalScrollbar.scrollLeft).toBe presenter.scrollWidth - presenter.clientWidth - - expectStateUpdate presenter, -> editor.getBuffer().delete([[6, 0], [6, Infinity]]) - expect(getState(presenter).horizontalScrollbar.scrollLeft).toBe presenter.scrollWidth - presenter.clientWidth - - # Scroll top only gets smaller when needed as dimensions change, never bigger - scrollLeftBefore = getState(presenter).horizontalScrollbar.scrollLeft - expectStateUpdate presenter, -> editor.getBuffer().insert([6, 0], new Array(100).join('x')) - expect(getState(presenter).horizontalScrollbar.scrollLeft).toBe scrollLeftBefore - - it "never goes negative", -> - presenter = buildPresenter(scrollLeft: 10, verticalScrollbarWidth: 10, contentFrameWidth: 500) - expectStateUpdate presenter, -> presenter.setScrollLeft(-300) - expect(getState(presenter).horizontalScrollbar.scrollLeft).toBe 0 - - it "is always 0 when soft wrapping is enabled", -> - presenter = buildPresenter(scrollLeft: 0, verticalScrollbarWidth: 0, contentFrameWidth: 85, baseCharacterWidth: 10) - - editor.update({softWrapped: false}) - presenter.setScrollLeft(Infinity) - expect(getState(presenter).content.scrollLeft).toBeGreaterThan 0 - - editor.update({softWrapped: true}) - expect(getState(presenter).content.scrollLeft).toBe 0 - presenter.setScrollLeft(10) - expect(getState(presenter).content.scrollLeft).toBe 0 - - it "is always 0 when `editor.autoWidth` is true", -> - editor.update({autoWidth: true}) - editor.setText('abcdefghijklm') - presenter = buildPresenter(explicitHeight: 10, contentFrameWidth: 30, verticalScrollbarWidth: 15, baseCharacterWidth: 10) - getState(presenter) # trigger a state update to store state in the presenter - - editor.setCursorBufferPosition([0, Infinity]) - editor.insertText('n') - expect(getState(presenter).content.scrollLeft).toBe(0) - - editor.setText('abcdefghijklm\nnopqrstuvwxy') # make the vertical scrollbar appear - editor.setCursorBufferPosition([1, Infinity]) - editor.insertText('z') - expect(getState(presenter).content.scrollLeft).toBe(0) - - describe ".verticalScrollbar", -> - describe ".visible", -> - it "is true if the scrollHeight exceeds the computed client height", -> - presenter = buildPresenter - model: editor - explicitHeight: editor.getLineCount() * 10 - contentFrameWidth: editor.getMaxScreenLineLength() * 10 + 1 - baseCharacterWidth: 10 - lineHeight: 10 - horizontalScrollbarHeight: 10 - verticalScrollbarWidth: 10 - - expect(getState(presenter).verticalScrollbar.visible).toBe false - - # ::explicitHeight itself is smaller than scrollWidth - presenter.setExplicitHeight(editor.getLineCount() * 10 - 1) - expect(getState(presenter).verticalScrollbar.visible).toBe true - - # restore... - presenter.setExplicitHeight(editor.getLineCount() * 10) - expect(getState(presenter).verticalScrollbar.visible).toBe false - - # visible horizontal scrollbar makes the clientHeight smaller than the scrollHeight - presenter.setContentFrameWidth(editor.getMaxScreenLineLength() * 10) - expect(getState(presenter).verticalScrollbar.visible).toBe true - - describe ".width", -> - it "is assigned based on ::verticalScrollbarWidth", -> - presenter = buildPresenter(verticalScrollbarWidth: 10) - expect(getState(presenter).verticalScrollbar.width).toBe 10 - expectStateUpdate presenter, -> presenter.setVerticalScrollbarWidth(20) - expect(getState(presenter).verticalScrollbar.width).toBe 20 - - describe ".bottom", -> - it "is ::horizontalScrollbarHeight if the horizontal scrollbar is visible and 0 otherwise", -> - presenter = buildPresenter - explicitHeight: editor.getLineCount() * 10 - 1 - contentFrameWidth: editor.getMaxScreenLineLength() * 10 + 50 - baseCharacterWidth: 10 - lineHeight: 10 - horizontalScrollbarHeight: 10 - verticalScrollbarWidth: 10 - - expect(getState(presenter).verticalScrollbar.bottom).toBe 0 - presenter.setContentFrameWidth(editor.getMaxScreenLineLength() * 10) - expect(getState(presenter).verticalScrollbar.bottom).toBe 10 - - describe ".scrollHeight", -> - it "is initialized based on the lineHeight, the number of lines, and the height", -> - presenter = buildPresenter(scrollTop: 0, lineHeight: 10) - expect(getState(presenter).verticalScrollbar.scrollHeight).toBe editor.getScreenLineCount() * 10 - - presenter = buildPresenter(scrollTop: 0, lineHeight: 10, explicitHeight: 500) - expect(getState(presenter).verticalScrollbar.scrollHeight).toBe 500 - - it "updates when new block decorations are measured, changed or destroyed", -> - presenter = buildPresenter(scrollTop: 0, lineHeight: 10) - expect(getState(presenter).verticalScrollbar.scrollHeight).toBe editor.getScreenLineCount() * 10 - - blockDecoration1 = addBlockDecorationBeforeScreenRow(0) - blockDecoration2 = addBlockDecorationBeforeScreenRow(3) - blockDecoration3 = addBlockDecorationBeforeScreenRow(7) - - presenter.setBlockDecorationDimensions(blockDecoration1, 0, 35.8) - presenter.setBlockDecorationDimensions(blockDecoration2, 0, 50.3) - presenter.setBlockDecorationDimensions(blockDecoration3, 0, 95.2) - - linesHeight = editor.getScreenLineCount() * 10 - blockDecorationsHeight = Math.round(35.8 + 50.3 + 95.2) - expect(getState(presenter).verticalScrollbar.scrollHeight).toBe(linesHeight + blockDecorationsHeight) - - presenter.setBlockDecorationDimensions(blockDecoration2, 0, 100.3) - - blockDecorationsHeight = Math.round(35.8 + 100.3 + 95.2) - expect(getState(presenter).verticalScrollbar.scrollHeight).toBe(linesHeight + blockDecorationsHeight) - - waitsForStateToUpdate presenter, -> blockDecoration3.destroy() - runs -> - blockDecorationsHeight = Math.round(35.8 + 100.3) - expect(getState(presenter).verticalScrollbar.scrollHeight).toBe(linesHeight + blockDecorationsHeight) - - it "updates when the ::lineHeight changes", -> - presenter = buildPresenter(scrollTop: 0, lineHeight: 10) - expectStateUpdate presenter, -> presenter.setLineHeight(20) - expect(getState(presenter).verticalScrollbar.scrollHeight).toBe editor.getScreenLineCount() * 20 - - it "updates when the line count changes", -> - presenter = buildPresenter(scrollTop: 0, lineHeight: 10) - expectStateUpdate presenter, -> editor.getBuffer().append("\n\n\n") - expect(getState(presenter).verticalScrollbar.scrollHeight).toBe editor.getScreenLineCount() * 10 - - it "updates when ::explicitHeight changes", -> - presenter = buildPresenter(scrollTop: 0, lineHeight: 10) - expectStateUpdate presenter, -> presenter.setExplicitHeight(500) - expect(getState(presenter).verticalScrollbar.scrollHeight).toBe 500 - - describe "scrollPastEnd", -> - it "adds the computed clientHeight to the computed scrollHeight if scrollPastEnd is enabled", -> - presenter = buildPresenter(scrollTop: 10, explicitHeight: 50, horizontalScrollbarHeight: 10) - expectStateUpdate presenter, -> presenter.setScrollTop(300) - expect(getState(presenter).verticalScrollbar.scrollHeight).toBe presenter.contentHeight - - expectStateUpdate presenter, -> editor.update({scrollPastEnd: true}) - expect(getState(presenter).verticalScrollbar.scrollHeight).toBe presenter.contentHeight + presenter.clientHeight - (presenter.lineHeight * 3) - - expectStateUpdate presenter, -> editor.update({scrollPastEnd: false}) - expect(getState(presenter).verticalScrollbar.scrollHeight).toBe presenter.contentHeight - - describe ".scrollTop", -> - it "tracks the value of ::scrollTop", -> - presenter = buildPresenter(scrollTop: 10, explicitHeight: 20, horizontalScrollbarHeight: 10) - expect(getState(presenter).verticalScrollbar.scrollTop).toBe 10 - expectStateUpdate presenter, -> presenter.setScrollTop(50) - expect(getState(presenter).verticalScrollbar.scrollTop).toBe 50 - - it "never exceeds the computed scrollHeight minus the computed clientHeight", -> - presenter = buildPresenter(scrollTop: 10, explicitHeight: 50, horizontalScrollbarHeight: 10) - expectStateUpdate presenter, -> presenter.setScrollTop(100) - expect(getState(presenter).verticalScrollbar.scrollTop).toBe presenter.scrollHeight - presenter.clientHeight - - expectStateUpdate presenter, -> presenter.setExplicitHeight(60) - expect(getState(presenter).verticalScrollbar.scrollTop).toBe presenter.scrollHeight - presenter.clientHeight - - expectStateUpdate presenter, -> presenter.setHorizontalScrollbarHeight(5) - expect(getState(presenter).verticalScrollbar.scrollTop).toBe presenter.scrollHeight - presenter.clientHeight - - expectStateUpdate presenter, -> editor.getBuffer().delete([[8, 0], [12, 0]]) - expect(getState(presenter).verticalScrollbar.scrollTop).toBe presenter.scrollHeight - presenter.clientHeight - - # Scroll top only gets smaller when needed as dimensions change, never bigger - scrollTopBefore = getState(presenter).verticalScrollbar.scrollTop - expectStateUpdate presenter, -> editor.getBuffer().insert([9, Infinity], '\n\n\n') - expect(getState(presenter).verticalScrollbar.scrollTop).toBe scrollTopBefore - - it "never goes negative", -> - presenter = buildPresenter(scrollTop: 10, explicitHeight: 50, horizontalScrollbarHeight: 10) - expectStateUpdate presenter, -> presenter.setScrollTop(-100) - expect(getState(presenter).verticalScrollbar.scrollTop).toBe 0 - - it "adds the computed clientHeight to the computed scrollHeight if scrollPastEnd is enabled", -> - presenter = buildPresenter(scrollTop: 10, explicitHeight: 50, horizontalScrollbarHeight: 10) - expectStateUpdate presenter, -> presenter.setScrollTop(300) - expect(getState(presenter).verticalScrollbar.scrollTop).toBe presenter.contentHeight - presenter.clientHeight - - editor.update({scrollPastEnd: true}) - expectStateUpdate presenter, -> presenter.setScrollTop(300) - expect(getState(presenter).verticalScrollbar.scrollTop).toBe presenter.contentHeight - (presenter.lineHeight * 3) - - expectStateUpdate presenter, -> editor.update({scrollPastEnd: false}) - expect(getState(presenter).verticalScrollbar.scrollTop).toBe presenter.contentHeight - presenter.clientHeight - - describe ".hiddenInput", -> - describe ".top/.left", -> - it "is positioned over the last cursor it is in view and the editor is focused", -> - editor.setCursorBufferPosition([3, 6]) - presenter = buildPresenter(focused: false, explicitHeight: 50, contentFrameWidth: 300, horizontalScrollbarHeight: 0, verticalScrollbarWidth: 0) - expectValues getState(presenter).hiddenInput, {top: 0, left: 0} - - expectStateUpdate presenter, -> presenter.setFocused(true) - expectValues getState(presenter).hiddenInput, {top: 3 * 10, left: 6 * 10} - - expectStateUpdate presenter, -> presenter.setScrollTop(15) - expectValues getState(presenter).hiddenInput, {top: (3 * 10) - 15, left: 6 * 10} - - expectStateUpdate presenter, -> presenter.setScrollLeft(35) - expectValues getState(presenter).hiddenInput, {top: (3 * 10) - 15, left: (6 * 10) - 35} - - expectStateUpdate presenter, -> presenter.setScrollTop(40) - expectValues getState(presenter).hiddenInput, {top: 0, left: (6 * 10) - 35} - - expectStateUpdate presenter, -> presenter.setScrollLeft(70) - expectValues getState(presenter).hiddenInput, {top: 0, left: 0} - - expectStateUpdate presenter, -> editor.setCursorBufferPosition([11, 43]) - expectValues getState(presenter).hiddenInput, {top: 11 * 10 - presenter.getScrollTop(), left: 43 * 10 - presenter.getScrollLeft()} - - newCursor = null - expectStateUpdate presenter, -> newCursor = editor.addCursorAtBufferPosition([6, 10]) - expectValues getState(presenter).hiddenInput, {top: (6 * 10) - presenter.getScrollTop(), left: (10 * 10) - presenter.getScrollLeft()} - - expectStateUpdate presenter, -> newCursor.destroy() - expectValues getState(presenter).hiddenInput, {top: 50 - 10, left: 300 - 10} - - expectStateUpdate presenter, -> presenter.setFocused(false) - expectValues getState(presenter).hiddenInput, {top: 0, left: 0} - - describe ".height", -> - it "is assigned based on the line height", -> - presenter = buildPresenter() - expect(getState(presenter).hiddenInput.height).toBe 10 - - expectStateUpdate presenter, -> presenter.setLineHeight(20) - expect(getState(presenter).hiddenInput.height).toBe 20 - - describe ".width", -> - it "is assigned based on the width of the character following the cursor", -> - waitsForPromise -> atom.packages.activatePackage('language-javascript') - - runs -> - editor.setGrammar(atom.grammars.grammarForScopeName('source.js')) - editor.setCursorBufferPosition([3, 6]) - presenter = buildPresenter() - expect(getState(presenter).hiddenInput.width).toBe 10 - - expectStateUpdate presenter, -> presenter.setBaseCharacterWidth(15) - expect(getState(presenter).hiddenInput.width).toBe 15 - - expectStateUpdate presenter, -> - presenter.getLinesYardstick().setScopedCharacterWidth(['syntax--source.syntax--js', 'syntax--storage.syntax--type.syntax--var.syntax--js'], 'r', 20) - presenter.measurementsChanged() - expect(getState(presenter).hiddenInput.width).toBe 20 - - it "is 2px at the end of lines", -> - presenter = buildPresenter() - editor.setCursorBufferPosition([3, Infinity]) - expect(getState(presenter).hiddenInput.width).toBe 2 - - describe ".content", -> - describe '.width', -> - describe "when `editor.autoWidth` is false (the default)", -> - it "equals to the max width between the content frame width and the content width + the vertical scrollbar width", -> - editor.setText('abc\ndef\nghi\njkl') - presenter = buildPresenter(explicitHeight: 10, contentFrameWidth: 33, verticalScrollbarWidth: 7, baseCharacterWidth: 10) - expect(getState(presenter).content.width).toBe(3 * 10 + 7 + 1) - presenter.setContentFrameWidth(50) - expect(getState(presenter).content.width).toBe(50) - presenter.setVerticalScrollbarWidth(27) - expect(getState(presenter).content.width).toBe(3 * 10 + 27 + 1) - - describe "when `editor.autoWidth` is true", -> - it "equals to the content width + the vertical scrollbar width", -> - editor.setText('abc\ndef\nghi\njkl') - presenter = buildPresenter(explicitHeight: 10, contentFrameWidth: 300, verticalScrollbarWidth: 7, baseCharacterWidth: 10) - expectStateUpdate presenter, -> editor.update({autoWidth: true}) - expect(getState(presenter).content.width).toBe(3 * 10 + 7 + 1) - editor.setText('abcdefghi\n') - expect(getState(presenter).content.width).toBe(9 * 10 + 7 + 1) - - it "ignores the vertical scrollbar width when it is unset", -> - editor.setText('abcdef\nghijkl') - presenter = buildPresenter(explicitHeight: 10, contentFrameWidth: 33, verticalScrollbarWidth: 7, baseCharacterWidth: 10) - presenter.setVerticalScrollbarWidth(null) - expect(getState(presenter).content.width).toBe(6 * 10 + 1) - - it "ignores the content frame width when it is unset", -> - editor.setText('abc\ndef\nghi\njkl') - presenter = buildPresenter(explicitHeight: 10, contentFrameWidth: 33, verticalScrollbarWidth: 7, baseCharacterWidth: 10) - getState(presenter) # trigger a state update, causing verticalScrollbarWidth to be stored in the presenter - presenter.setContentFrameWidth(null) - expect(getState(presenter).content.width).toBe(3 * 10 + 7 + 1) - - describe ".maxHeight", -> - it "changes based on boundingClientRect", -> - presenter = buildPresenter(scrollTop: 0, lineHeight: 10) - - expectStateUpdate presenter, -> - presenter.setBoundingClientRect(left: 0, top: 0, height: 20, width: 0) - expect(getState(presenter).content.maxHeight).toBe(20) - - expectStateUpdate presenter, -> - presenter.setBoundingClientRect(left: 0, top: 0, height: 50, width: 0) - expect(getState(presenter).content.maxHeight).toBe(50) - - describe ".scrollHeight", -> - it "updates when new block decorations are measured, changed or destroyed", -> - presenter = buildPresenter(scrollTop: 0, lineHeight: 10) - expect(getState(presenter).verticalScrollbar.scrollHeight).toBe editor.getScreenLineCount() * 10 - - blockDecoration1 = addBlockDecorationBeforeScreenRow(0) - blockDecoration2 = addBlockDecorationBeforeScreenRow(3) - blockDecoration3 = addBlockDecorationBeforeScreenRow(7) - - presenter.setBlockDecorationDimensions(blockDecoration1, 0, 35.8) - presenter.setBlockDecorationDimensions(blockDecoration2, 0, 50.3) - presenter.setBlockDecorationDimensions(blockDecoration3, 0, 95.2) - - linesHeight = editor.getScreenLineCount() * 10 - blockDecorationsHeight = Math.round(35.8 + 50.3 + 95.2) - expect(getState(presenter).content.scrollHeight).toBe(linesHeight + blockDecorationsHeight) - - presenter.setBlockDecorationDimensions(blockDecoration2, 0, 100.3) - - blockDecorationsHeight = Math.round(35.8 + 100.3 + 95.2) - expect(getState(presenter).content.scrollHeight).toBe(linesHeight + blockDecorationsHeight) - - waitsForStateToUpdate presenter, -> blockDecoration3.destroy() - runs -> - blockDecorationsHeight = Math.round(35.8 + 100.3) - expect(getState(presenter).content.scrollHeight).toBe(linesHeight + blockDecorationsHeight) - - it "is initialized based on the lineHeight, the number of lines, and the height", -> - presenter = buildPresenter(scrollTop: 0, lineHeight: 10) - expect(getState(presenter).content.scrollHeight).toBe editor.getScreenLineCount() * 10 - - presenter = buildPresenter(scrollTop: 0, lineHeight: 10, explicitHeight: 500) - expect(getState(presenter).content.scrollHeight).toBe 500 - - it "updates when the ::lineHeight changes", -> - presenter = buildPresenter(scrollTop: 0, lineHeight: 10) - expectStateUpdate presenter, -> presenter.setLineHeight(20) - expect(getState(presenter).content.scrollHeight).toBe editor.getScreenLineCount() * 20 - - it "updates when the line count changes", -> - presenter = buildPresenter(scrollTop: 0, lineHeight: 10) - expectStateUpdate presenter, -> editor.getBuffer().append("\n\n\n") - expect(getState(presenter).content.scrollHeight).toBe editor.getScreenLineCount() * 10 - - it "updates when ::explicitHeight changes", -> - presenter = buildPresenter(scrollTop: 0, lineHeight: 10) - expectStateUpdate presenter, -> presenter.setExplicitHeight(500) - expect(getState(presenter).content.scrollHeight).toBe 500 - - it "adds the computed clientHeight to the computed scrollHeight if scrollPastEnd is enabled", -> - presenter = buildPresenter(scrollTop: 10, explicitHeight: 50, horizontalScrollbarHeight: 10) - expectStateUpdate presenter, -> presenter.setScrollTop(300) - expect(getState(presenter).content.scrollHeight).toBe presenter.contentHeight - - expectStateUpdate presenter, -> editor.update({scrollPastEnd: true}) - expect(getState(presenter).content.scrollHeight).toBe presenter.contentHeight + presenter.clientHeight - (presenter.lineHeight * 3) - - expectStateUpdate presenter, -> editor.update({scrollPastEnd: false}) - expect(getState(presenter).content.scrollHeight).toBe presenter.contentHeight - - describe ".scrollWidth", -> - it "is initialized as the max of the computed clientWidth and the width of the longest line", -> - maxLineLength = editor.getMaxScreenLineLength() - - presenter = buildPresenter(explicitHeight: 100, contentFrameWidth: 50, baseCharacterWidth: 10, verticalScrollbarWidth: 10) - expect(getState(presenter).content.scrollWidth).toBe 10 * maxLineLength + 1 - - presenter = buildPresenter(explicitHeight: 100, contentFrameWidth: 10 * maxLineLength + 20, baseCharacterWidth: 10, verticalScrollbarWidth: 10) - expect(getState(presenter).content.scrollWidth).toBe 10 * maxLineLength + 20 - 10 # subtract vertical scrollbar width - - describe "when the longest screen row is the first one and it's hidden", -> - it "doesn't compute an invalid value (regression)", -> - presenter = buildPresenter(tileSize: 2, contentFrameWidth: 10, explicitHeight: 20) - editor.setText """ - a very long long long long long long line - b - c - d - e - """ - - expectStateUpdate presenter, -> presenter.setScrollTop(40) - expect(getState(presenter).content.scrollWidth).toBe 10 * editor.getMaxScreenLineLength() + 1 - - it "updates when the ::contentFrameWidth changes", -> - maxLineLength = editor.getMaxScreenLineLength() - presenter = buildPresenter(contentFrameWidth: 50, baseCharacterWidth: 10) - - expect(getState(presenter).content.scrollWidth).toBe 10 * maxLineLength + 1 - expectStateUpdate presenter, -> presenter.setContentFrameWidth(10 * maxLineLength + 20) - expect(getState(presenter).content.scrollWidth).toBe 10 * maxLineLength + 20 - - it "updates when character widths change", -> - waitsForPromise -> atom.packages.activatePackage('language-javascript') - - runs -> - editor.setGrammar(atom.grammars.grammarForScopeName('source.js')) - maxLineLength = editor.getMaxScreenLineLength() - presenter = buildPresenter(contentFrameWidth: 50, baseCharacterWidth: 10) - - expect(getState(presenter).content.scrollWidth).toBe 10 * maxLineLength + 1 - expectStateUpdate presenter, -> - presenter.getLinesYardstick().setScopedCharacterWidth(['syntax--source.syntax--js', 'syntax--meta.syntax--method-call.syntax--js', 'syntax--support.syntax--function.syntax--js'], 'p', 20) - presenter.measurementsChanged() - expect(getState(presenter).content.scrollWidth).toBe (10 * (maxLineLength - 2)) + (20 * 2) + 1 # 2 of the characters are 20px wide now instead of 10px wide - - it "updates when ::softWrapped changes on the editor", -> - presenter = buildPresenter(contentFrameWidth: 470, baseCharacterWidth: 10) - expect(getState(presenter).content.scrollWidth).toBe 10 * editor.getMaxScreenLineLength() + 1 - expectStateUpdate presenter, -> editor.update({softWrapped: true}) - expect(getState(presenter).horizontalScrollbar.scrollWidth).toBe presenter.clientWidth - expectStateUpdate presenter, -> editor.update({softWrapped: false}) - expect(getState(presenter).content.scrollWidth).toBe 10 * editor.getMaxScreenLineLength() + 1 - - it "updates when the longest line changes", -> - presenter = buildPresenter(contentFrameWidth: 50, baseCharacterWidth: 10) - - expect(getState(presenter).content.scrollWidth).toBe 10 * editor.getMaxScreenLineLength() + 1 - - expectStateUpdate presenter, -> editor.setCursorBufferPosition([editor.getLongestScreenRow(), 0]) - expectStateUpdate presenter, -> editor.insertText('xyz') - - expect(getState(presenter).content.scrollWidth).toBe 10 * editor.getMaxScreenLineLength() + 1 - - it "isn't clipped to 0 when the longest line is folded (regression)", -> - presenter = buildPresenter(contentFrameWidth: 50, baseCharacterWidth: 10) - editor.foldBufferRow(0) - expect(getState(presenter).content.scrollWidth).toBe 10 * editor.getMaxScreenLineLength() + 1 - - describe ".scrollTop", -> - it "doesn't get stuck when repeatedly setting the same non-integer position in a scroll event listener", -> - presenter = buildPresenter(scrollTop: 0, lineHeight: 10, explicitHeight: 20) - expect(getState(presenter).content.scrollTop).toBe(0) - - presenter.onDidChangeScrollTop -> - presenter.setScrollTop(1.5) - getState(presenter) # trigger scroll update - - presenter.setScrollTop(1.5) - getState(presenter) # trigger scroll update - - expect(presenter.getScrollTop()).toBe(2) - expect(presenter.getRealScrollTop()).toBe(1.5) - - it "changes based on the scroll operation that was performed last", -> - presenter = buildPresenter(scrollTop: 0, lineHeight: 10, explicitHeight: 20) - expect(getState(presenter).content.scrollTop).toBe(0) - - presenter.setScrollTop(20) - editor.setCursorBufferPosition([5, 0]) - - expect(getState(presenter).content.scrollTop).toBe(50) - - editor.setCursorBufferPosition([8, 0]) - presenter.setScrollTop(10) - - expect(getState(presenter).content.scrollTop).toBe(10) - - it "corresponds to the passed logical coordinates when building the presenter", -> - editor.setFirstVisibleScreenRow(4) - presenter = buildPresenter(lineHeight: 10, explicitHeight: 20) - expect(getState(presenter).content.scrollTop).toBe(40) - - it "tracks the value of ::scrollTop", -> - presenter = buildPresenter(scrollTop: 10, lineHeight: 10, explicitHeight: 20) - expect(getState(presenter).content.scrollTop).toBe 10 - expectStateUpdate presenter, -> presenter.setScrollTop(50) - expect(getState(presenter).content.scrollTop).toBe 50 - - it "keeps the model up to date with the corresponding logical coordinates", -> - presenter = buildPresenter(scrollTop: 0, explicitHeight: 20, horizontalScrollbarHeight: 10, lineHeight: 10) - - expectStateUpdate presenter, -> presenter.setScrollTop(50) - getState(presenter) # commits scroll position - expect(editor.getFirstVisibleScreenRow()).toBe 5 - - expectStateUpdate presenter, -> presenter.setScrollTop(57) - getState(presenter) # commits scroll position - expect(editor.getFirstVisibleScreenRow()).toBe 6 - - it "updates when the model's scroll position is changed directly", -> - presenter = buildPresenter(scrollTop: 0, explicitHeight: 20, horizontalScrollbarHeight: 10, lineHeight: 10) - expectStateUpdate presenter, -> editor.setFirstVisibleScreenRow(1) - expect(getState(presenter).content.scrollTop).toBe 10 - - it "reassigns the scrollTop if it exceeds the max possible value after lines are removed", -> - presenter = buildPresenter(scrollTop: 80, lineHeight: 10, explicitHeight: 50, horizontalScrollbarHeight: 0) - expect(getState(presenter).content.scrollTop).toBe(80) - buffer.deleteRows(10, 9, 8) - expect(getState(presenter).content.scrollTop).toBe(60) - - it "is always rounded to the nearest integer", -> - presenter = buildPresenter(scrollTop: 10, lineHeight: 10, explicitHeight: 20) - expect(getState(presenter).content.scrollTop).toBe 10 - expectStateUpdate presenter, -> presenter.setScrollTop(11.4) - expect(getState(presenter).content.scrollTop).toBe 11 - expectStateUpdate presenter, -> presenter.setScrollTop(12.6) - expect(getState(presenter).content.scrollTop).toBe 13 - - it "scrolls down automatically when the model is changed", -> - presenter = buildPresenter(scrollTop: 0, lineHeight: 10, explicitHeight: 20) - - editor.setText("") - editor.insertNewline() - expect(getState(presenter).content.scrollTop).toBe(0) - - editor.insertNewline() - expect(getState(presenter).content.scrollTop).toBe(10) - - editor.insertNewline() - expect(getState(presenter).content.scrollTop).toBe(20) - - it "never exceeds the computed scroll height minus the computed client height", -> - didChangeScrollTopSpy = jasmine.createSpy() - presenter = buildPresenter(scrollTop: 10, lineHeight: 10, explicitHeight: 50, horizontalScrollbarHeight: 10) - presenter.onDidChangeScrollTop(didChangeScrollTopSpy) - - expectStateUpdate presenter, -> presenter.setScrollTop(100) - expect(getState(presenter).content.scrollTop).toBe presenter.scrollHeight - presenter.clientHeight - expect(presenter.getRealScrollTop()).toBe presenter.scrollHeight - presenter.clientHeight - expect(didChangeScrollTopSpy).toHaveBeenCalledWith presenter.scrollHeight - presenter.clientHeight - - didChangeScrollTopSpy.reset() - expectStateUpdate presenter, -> presenter.setExplicitHeight(60) - expect(getState(presenter).content.scrollTop).toBe presenter.scrollHeight - presenter.clientHeight - expect(presenter.getRealScrollTop()).toBe presenter.scrollHeight - presenter.clientHeight - expect(didChangeScrollTopSpy).toHaveBeenCalledWith presenter.scrollHeight - presenter.clientHeight - - didChangeScrollTopSpy.reset() - expectStateUpdate presenter, -> presenter.setHorizontalScrollbarHeight(5) - expect(getState(presenter).content.scrollTop).toBe presenter.scrollHeight - presenter.clientHeight - expect(presenter.getRealScrollTop()).toBe presenter.scrollHeight - presenter.clientHeight - expect(didChangeScrollTopSpy).toHaveBeenCalledWith presenter.scrollHeight - presenter.clientHeight - - didChangeScrollTopSpy.reset() - expectStateUpdate presenter, -> editor.getBuffer().delete([[8, 0], [12, 0]]) - expect(getState(presenter).content.scrollTop).toBe presenter.scrollHeight - presenter.clientHeight - expect(presenter.getRealScrollTop()).toBe presenter.scrollHeight - presenter.clientHeight - expect(didChangeScrollTopSpy).toHaveBeenCalledWith presenter.scrollHeight - presenter.clientHeight - - # Scroll top only gets smaller when needed as dimensions change, never bigger - scrollTopBefore = getState(presenter).verticalScrollbar.scrollTop - didChangeScrollTopSpy.reset() - expectStateUpdate presenter, -> editor.getBuffer().insert([9, Infinity], '\n\n\n') - expect(getState(presenter).content.scrollTop).toBe scrollTopBefore - expect(presenter.getRealScrollTop()).toBe scrollTopBefore - expect(didChangeScrollTopSpy).not.toHaveBeenCalled() - - it "never goes negative", -> - presenter = buildPresenter(scrollTop: 10, explicitHeight: 50, horizontalScrollbarHeight: 10) - expectStateUpdate presenter, -> presenter.setScrollTop(-100) - expect(getState(presenter).content.scrollTop).toBe 0 - - it "adds the computed clientHeight to the computed scrollHeight if scrollPastEnd is enabled", -> - presenter = buildPresenter(scrollTop: 10, explicitHeight: 50, horizontalScrollbarHeight: 10) - expectStateUpdate presenter, -> presenter.setScrollTop(300) - expect(getState(presenter).content.scrollTop).toBe presenter.contentHeight - presenter.clientHeight - - editor.update({scrollPastEnd: true}) - expectStateUpdate presenter, -> presenter.setScrollTop(300) - expect(getState(presenter).content.scrollTop).toBe presenter.contentHeight - (presenter.lineHeight * 3) - - expectStateUpdate presenter, -> editor.update({scrollPastEnd: false}) - expect(getState(presenter).content.scrollTop).toBe presenter.contentHeight - presenter.clientHeight - - describe ".scrollLeft", -> - it "doesn't get stuck when repeatedly setting the same non-integer position in a scroll event listener", -> - presenter = buildPresenter(scrollLeft: 0, lineHeight: 10, baseCharacterWidth: 10, verticalScrollbarWidth: 10, contentFrameWidth: 10) - expect(getState(presenter).content.scrollLeft).toBe(0) - - presenter.onDidChangeScrollLeft -> - presenter.setScrollLeft(1.5) - getState(presenter) # trigger scroll update - - presenter.setScrollLeft(1.5) - getState(presenter) # trigger scroll update - - expect(presenter.getScrollLeft()).toBe(2) - expect(presenter.getRealScrollLeft()).toBe(1.5) - - it "changes based on the scroll operation that was performed last", -> - presenter = buildPresenter(scrollLeft: 0, lineHeight: 10, baseCharacterWidth: 10, verticalScrollbarWidth: 10, contentFrameWidth: 10) - expect(getState(presenter).content.scrollLeft).toBe(0) - - presenter.setScrollLeft(20) - editor.setCursorBufferPosition([0, 9]) - - expect(getState(presenter).content.scrollLeft).toBe(90) - - editor.setCursorBufferPosition([0, 18]) - presenter.setScrollLeft(50) - - expect(getState(presenter).content.scrollLeft).toBe(50) - - it "corresponds to the passed logical coordinates when building the presenter", -> - editor.setFirstVisibleScreenColumn(3) - presenter = buildPresenter(lineHeight: 10, baseCharacterWidth: 10, verticalScrollbarWidth: 10, contentFrameWidth: 500) - expect(getState(presenter).content.scrollLeft).toBe(30) - - it "tracks the value of ::scrollLeft", -> - presenter = buildPresenter(scrollLeft: 10, lineHeight: 10, baseCharacterWidth: 10, verticalScrollbarWidth: 10, contentFrameWidth: 500) - expect(getState(presenter).content.scrollLeft).toBe 10 - expectStateUpdate presenter, -> presenter.setScrollLeft(50) - expect(getState(presenter).content.scrollLeft).toBe 50 - - it "keeps the model up to date with the corresponding logical coordinates", -> - presenter = buildPresenter(scrollLeft: 0, lineHeight: 10, baseCharacterWidth: 10, verticalScrollbarWidth: 10, contentFrameWidth: 500) - - expectStateUpdate presenter, -> presenter.setScrollLeft(50) - getState(presenter) # commits scroll position - expect(editor.getFirstVisibleScreenColumn()).toBe 5 - - expectStateUpdate presenter, -> presenter.setScrollLeft(57) - getState(presenter) # commits scroll position - expect(editor.getFirstVisibleScreenColumn()).toBe 6 - - it "is always rounded to the nearest integer", -> - presenter = buildPresenter(scrollLeft: 10, lineHeight: 10, baseCharacterWidth: 10, verticalScrollbarWidth: 10, contentFrameWidth: 500) - expect(getState(presenter).content.scrollLeft).toBe 10 - expectStateUpdate presenter, -> presenter.setScrollLeft(11.4) - expect(getState(presenter).content.scrollLeft).toBe 11 - expectStateUpdate presenter, -> presenter.setScrollLeft(12.6) - expect(getState(presenter).content.scrollLeft).toBe 13 - - it "never exceeds the computed scrollWidth minus the computed clientWidth", -> - didChangeScrollLeftSpy = jasmine.createSpy() - presenter = buildPresenter(scrollLeft: 10, lineHeight: 10, baseCharacterWidth: 10, verticalScrollbarWidth: 10, contentFrameWidth: 500) - presenter.onDidChangeScrollLeft(didChangeScrollLeftSpy) - - expectStateUpdate presenter, -> presenter.setScrollLeft(300) - expect(getState(presenter).content.scrollLeft).toBe presenter.scrollWidth - presenter.clientWidth - expect(presenter.getRealScrollLeft()).toBe presenter.scrollWidth - presenter.clientWidth - expect(didChangeScrollLeftSpy).toHaveBeenCalledWith presenter.scrollWidth - presenter.clientWidth - - didChangeScrollLeftSpy.reset() - expectStateUpdate presenter, -> presenter.setContentFrameWidth(600) - expect(getState(presenter).content.scrollLeft).toBe presenter.scrollWidth - presenter.clientWidth - expect(presenter.getRealScrollLeft()).toBe presenter.scrollWidth - presenter.clientWidth - expect(didChangeScrollLeftSpy).toHaveBeenCalledWith presenter.scrollWidth - presenter.clientWidth - - didChangeScrollLeftSpy.reset() - expectStateUpdate presenter, -> presenter.setVerticalScrollbarWidth(5) - expect(getState(presenter).content.scrollLeft).toBe presenter.scrollWidth - presenter.clientWidth - expect(presenter.getRealScrollLeft()).toBe presenter.scrollWidth - presenter.clientWidth - expect(didChangeScrollLeftSpy).toHaveBeenCalledWith presenter.scrollWidth - presenter.clientWidth - - didChangeScrollLeftSpy.reset() - expectStateUpdate presenter, -> editor.getBuffer().delete([[6, 0], [6, Infinity]]) - expect(getState(presenter).content.scrollLeft).toBe presenter.scrollWidth - presenter.clientWidth - expect(presenter.getRealScrollLeft()).toBe presenter.scrollWidth - presenter.clientWidth - expect(didChangeScrollLeftSpy).toHaveBeenCalledWith presenter.scrollWidth - presenter.clientWidth - - # Scroll top only gets smaller when needed as dimensions change, never bigger - scrollLeftBefore = getState(presenter).content.scrollLeft - didChangeScrollLeftSpy.reset() - expectStateUpdate presenter, -> editor.getBuffer().insert([6, 0], new Array(100).join('x')) - expect(getState(presenter).content.scrollLeft).toBe scrollLeftBefore - expect(presenter.getRealScrollLeft()).toBe scrollLeftBefore - expect(didChangeScrollLeftSpy).not.toHaveBeenCalled() - - it "never goes negative", -> - presenter = buildPresenter(scrollLeft: 10, verticalScrollbarWidth: 10, contentFrameWidth: 500) - expectStateUpdate presenter, -> presenter.setScrollLeft(-300) - expect(getState(presenter).content.scrollLeft).toBe 0 - - describe ".backgroundColor", -> - it "is assigned to ::backgroundColor unless the editor is mini", -> - presenter = buildPresenter() - presenter.setBackgroundColor('rgba(255, 0, 0, 0)') - expect(getState(presenter).content.backgroundColor).toBe 'rgba(255, 0, 0, 0)' - - editor.setMini(true) - presenter = buildPresenter() - presenter.setBackgroundColor('rgba(255, 0, 0, 0)') - expect(getState(presenter).content.backgroundColor).toBeNull() - - it "updates when ::backgroundColor changes", -> - presenter = buildPresenter() - presenter.setBackgroundColor('rgba(255, 0, 0, 0)') - expect(getState(presenter).content.backgroundColor).toBe 'rgba(255, 0, 0, 0)' - expectStateUpdate presenter, -> presenter.setBackgroundColor('rgba(0, 0, 255, 0)') - expect(getState(presenter).content.backgroundColor).toBe 'rgba(0, 0, 255, 0)' - - it "updates when ::mini changes", -> - presenter = buildPresenter() - presenter.setBackgroundColor('rgba(255, 0, 0, 0)') - expect(getState(presenter).content.backgroundColor).toBe 'rgba(255, 0, 0, 0)' - expectStateUpdate presenter, -> editor.setMini(true) - expect(getState(presenter).content.backgroundColor).toBeNull() - - describe ".placeholderText", -> - it "is present when the editor has no text", -> - editor.setPlaceholderText("the-placeholder-text") - presenter = buildPresenter() - expect(getState(presenter).content.placeholderText).toBeNull() - - expectStateUpdate presenter, -> editor.setText("") - expect(getState(presenter).content.placeholderText).toBe "the-placeholder-text" - - expectStateUpdate presenter, -> editor.setPlaceholderText("new-placeholder-text") - expect(getState(presenter).content.placeholderText).toBe "new-placeholder-text" - - describe ".tiles", -> - lineStateForScreenRow = (presenter, row) -> - tilesState = getState(presenter).content.tiles - lineId = presenter.linesByScreenRow.get(row)?.id - tilesState[presenter.tileForRow(row)]?.lines[lineId] - - tagsForCodes = (presenter, tagCodes) -> - openTags = [] - closeTags = [] - for tagCode in tagCodes when tagCode < 0 # skip text codes - if presenter.isOpenTagCode(tagCode) - openTags.push(presenter.tagForCode(tagCode)) - else - closeTags.push(presenter.tagForCode(tagCode)) - {openTags, closeTags} - - tiledContentContract (presenter) -> getState(presenter).content - - describe "[tileId].lines[lineId]", -> # line state objects - it "includes the state for visible lines in a tile", -> - presenter = buildPresenter(explicitHeight: 3, scrollTop: 4, lineHeight: 1, tileSize: 3, stoppedScrollingDelay: 200) - presenter.setExplicitHeight(3) - - expect(lineStateForScreenRow(presenter, 2)).toBeUndefined() - expectValues lineStateForScreenRow(presenter, 3), {screenRow: 3, tagCodes: editor.screenLineForScreenRow(3).tagCodes} - expectValues lineStateForScreenRow(presenter, 4), {screenRow: 4, tagCodes: editor.screenLineForScreenRow(4).tagCodes} - expectValues lineStateForScreenRow(presenter, 5), {screenRow: 5, tagCodes: editor.screenLineForScreenRow(5).tagCodes} - expectValues lineStateForScreenRow(presenter, 6), {screenRow: 6, tagCodes: editor.screenLineForScreenRow(6).tagCodes} - expectValues lineStateForScreenRow(presenter, 7), {screenRow: 7, tagCodes: editor.screenLineForScreenRow(7).tagCodes} - expectValues lineStateForScreenRow(presenter, 8), {screenRow: 8, tagCodes: editor.screenLineForScreenRow(8).tagCodes} - expect(lineStateForScreenRow(presenter, 9)).toBeUndefined() - - it "updates when the editor's content changes", -> - presenter = buildPresenter(explicitHeight: 25, scrollTop: 10, lineHeight: 10, tileSize: 2) - - expectStateUpdate presenter, -> buffer.insert([2, 0], "hello\nworld\n") - - expectValues lineStateForScreenRow(presenter, 1), {screenRow: 1, tagCodes: editor.screenLineForScreenRow(1).tagCodes} - expectValues lineStateForScreenRow(presenter, 2), {screenRow: 2, tagCodes: editor.screenLineForScreenRow(2).tagCodes} - expectValues lineStateForScreenRow(presenter, 3), {screenRow: 3, tagCodes: editor.screenLineForScreenRow(3).tagCodes} - - it "includes the .endOfLineInvisibles if the editor.showInvisibles config option is true", -> - editor.update({showInvisibles: false, invisibles: {eol: 'X'}}) - - editor.setText("hello\nworld\r\n") - presenter = buildPresenter(explicitHeight: 25, scrollTop: 0, lineHeight: 10) - expect(tagsForCodes(presenter, lineStateForScreenRow(presenter, 0).tagCodes).openTags).not.toContain('invisible-character eol') - expect(tagsForCodes(presenter, lineStateForScreenRow(presenter, 1).tagCodes).openTags).not.toContain('invisible-character eol') - - editor.update({showInvisibles: true}) - presenter = buildPresenter(explicitHeight: 25, scrollTop: 0, lineHeight: 10) - expect(tagsForCodes(presenter, lineStateForScreenRow(presenter, 0).tagCodes).openTags).toContain('invisible-character eol') - expect(tagsForCodes(presenter, lineStateForScreenRow(presenter, 1).tagCodes).openTags).toContain('invisible-character eol') - - describe ".{preceding,following}BlockDecorations", -> - stateForBlockDecorations = (blockDecorations) -> - state = {} - for blockDecoration in blockDecorations - state[blockDecoration.id] = { - decoration: blockDecoration, - screenRow: blockDecoration.getMarker().getHeadScreenPosition().row - } - state - - it "contains all block decorations that are present before/after a line, both initially and when decorations change", -> - blockDecoration1 = addBlockDecorationBeforeScreenRow(0) - presenter = buildPresenter() - blockDecoration2 = addBlockDecorationBeforeScreenRow(3) - blockDecoration3 = addBlockDecorationBeforeScreenRow(7) - blockDecoration4 = null - - waitsForStateToUpdate presenter, -> - blockDecoration4 = addBlockDecorationAfterScreenRow(7) - - runs -> - expect(lineStateForScreenRow(presenter, 0).precedingBlockDecorations).toEqual(stateForBlockDecorations([blockDecoration1])) - expect(lineStateForScreenRow(presenter, 0).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 1).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 1).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 2).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 2).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 3).precedingBlockDecorations).toEqual(stateForBlockDecorations([blockDecoration2])) - expect(lineStateForScreenRow(presenter, 3).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 4).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 4).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 5).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 5).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 6).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 6).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 7).precedingBlockDecorations).toEqual(stateForBlockDecorations([blockDecoration3])) - expect(lineStateForScreenRow(presenter, 7).followingBlockDecorations).toEqual(stateForBlockDecorations([blockDecoration4])) - expect(lineStateForScreenRow(presenter, 8).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 8).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 9).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 9).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 10).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 10).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 11).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 11).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 12).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 12).followingBlockDecorations).toEqual({}) - - waitsForStateToUpdate presenter, -> - blockDecoration1.getMarker().setHeadBufferPosition([1, 0]) - blockDecoration2.getMarker().setHeadBufferPosition([9, 0]) - blockDecoration3.getMarker().setHeadBufferPosition([9, 0]) - blockDecoration4.getMarker().setHeadBufferPosition([8, 0]) - - runs -> - expect(lineStateForScreenRow(presenter, 0).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 0).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 1).precedingBlockDecorations).toEqual(stateForBlockDecorations([blockDecoration1])) - expect(lineStateForScreenRow(presenter, 1).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 2).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 2).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 3).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 3).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 4).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 4).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 5).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 5).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 6).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 6).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 7).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 7).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 8).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 8).followingBlockDecorations).toEqual(stateForBlockDecorations([blockDecoration4])) - expect(lineStateForScreenRow(presenter, 9).precedingBlockDecorations).toEqual(stateForBlockDecorations([blockDecoration2, blockDecoration3])) - expect(lineStateForScreenRow(presenter, 9).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 10).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 10).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 11).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 11).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 12).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 12).followingBlockDecorations).toEqual({}) - - waitsForStateToUpdate presenter, -> - blockDecoration4.destroy() - blockDecoration3.destroy() - blockDecoration1.getMarker().setHeadBufferPosition([0, 0]) - - runs -> - expect(lineStateForScreenRow(presenter, 0).precedingBlockDecorations).toEqual(stateForBlockDecorations([blockDecoration1])) - expect(lineStateForScreenRow(presenter, 0).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 1).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 1).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 2).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 2).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 3).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 3).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 4).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 4).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 5).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 5).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 6).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 6).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 7).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 7).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 8).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 8).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 9).precedingBlockDecorations).toEqual(stateForBlockDecorations([blockDecoration2])) - expect(lineStateForScreenRow(presenter, 9).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 10).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 10).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 11).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 11).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 12).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 12).followingBlockDecorations).toEqual({}) - - waitsForStateToUpdate presenter, -> - editor.setCursorBufferPosition([0, 0]) - editor.insertNewline() - - runs -> - expect(lineStateForScreenRow(presenter, 0).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 0).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 1).precedingBlockDecorations).toEqual(stateForBlockDecorations([blockDecoration1])) - expect(lineStateForScreenRow(presenter, 1).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 2).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 2).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 3).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 3).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 4).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 4).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 5).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 5).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 6).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 6).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 7).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 7).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 8).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 8).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 9).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 9).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 10).precedingBlockDecorations).toEqual(stateForBlockDecorations([blockDecoration2])) - expect(lineStateForScreenRow(presenter, 10).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 11).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 11).followingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 12).precedingBlockDecorations).toEqual({}) - expect(lineStateForScreenRow(presenter, 12).followingBlockDecorations).toEqual({}) - - it "contains block decorations located in ::mouseWheelScreenRow even if they are off screen", -> - blockDecoration = addBlockDecorationBeforeScreenRow(0) - presenter = buildPresenter(explicitHeight: 6, scrollTop: 0, lineHeight: 1, tileSize: 2, stoppedScrollingDelay: 200) - lineId = presenter.displayLayer.getScreenLines(0, 1)[0].id - - expect(getState(presenter).content.tiles[0].lines[lineId].precedingBlockDecorations).toEqual(stateForBlockDecorations([blockDecoration])) - - presenter.setMouseWheelScreenRow(0) - expectStateUpdate presenter, -> presenter.setScrollTop(4) - expect(getState(presenter).content.tiles[0].lines[lineId].precedingBlockDecorations).toEqual(stateForBlockDecorations([blockDecoration])) - - advanceClock(presenter.stoppedScrollingDelay) - expect(getState(presenter).content.tiles[0]).toBeUndefined() - - it "inserts block decorations before the line unless otherwise specified", -> - blockDecoration = editor.decorateMarker(editor.markScreenPosition([4, 0]), {type: "block"}) - presenter = buildPresenter() - - expect(lineStateForScreenRow(presenter, 4).precedingBlockDecorations).toEqual stateForBlockDecorations([blockDecoration]) - expect(lineStateForScreenRow(presenter, 4).followingBlockDecorations).toEqual {} - - describe ".decorationClasses", -> - it "adds decoration classes to the relevant line state objects, both initially and when decorations change", -> - marker1 = editor.addMarkerLayer(maintainHistory: true).markBufferRange([[4, 0], [6, 2]], invalidate: 'touch') - decoration1 = editor.decorateMarker(marker1, type: 'line', class: 'a') - presenter = buildPresenter() - marker2 = editor.addMarkerLayer(maintainHistory: true).markBufferRange([[4, 0], [6, 2]], invalidate: 'touch') - decoration2 = null - - waitsForStateToUpdate presenter, -> decoration2 = editor.decorateMarker(marker2, type: 'line', class: 'b') - runs -> - expect(lineStateForScreenRow(presenter, 3).decorationClasses).toBeNull() - expect(lineStateForScreenRow(presenter, 4).decorationClasses).toEqual ['a', 'b'] - expect(lineStateForScreenRow(presenter, 5).decorationClasses).toEqual ['a', 'b'] - expect(lineStateForScreenRow(presenter, 6).decorationClasses).toEqual ['a', 'b'] - expect(lineStateForScreenRow(presenter, 7).decorationClasses).toBeNull() - - waitsForStateToUpdate presenter, -> editor.getBuffer().insert([5, 0], 'x') - runs -> - expect(marker1.isValid()).toBe false - expect(lineStateForScreenRow(presenter, 4).decorationClasses).toBeNull() - expect(lineStateForScreenRow(presenter, 5).decorationClasses).toBeNull() - expect(lineStateForScreenRow(presenter, 6).decorationClasses).toBeNull() - - waitsForStateToUpdate presenter, -> editor.undo() - runs -> - expect(lineStateForScreenRow(presenter, 3).decorationClasses).toBeNull() - expect(lineStateForScreenRow(presenter, 4).decorationClasses).toEqual ['a', 'b'] - expect(lineStateForScreenRow(presenter, 5).decorationClasses).toEqual ['a', 'b'] - expect(lineStateForScreenRow(presenter, 6).decorationClasses).toEqual ['a', 'b'] - expect(lineStateForScreenRow(presenter, 7).decorationClasses).toBeNull() - - waitsForStateToUpdate presenter, -> marker1.setBufferRange([[2, 0], [4, 2]]) - runs -> - expect(lineStateForScreenRow(presenter, 1).decorationClasses).toBeNull() - expect(lineStateForScreenRow(presenter, 2).decorationClasses).toEqual ['a'] - expect(lineStateForScreenRow(presenter, 3).decorationClasses).toEqual ['a'] - expect(lineStateForScreenRow(presenter, 4).decorationClasses).toEqual ['a', 'b'] - expect(lineStateForScreenRow(presenter, 5).decorationClasses).toEqual ['b'] - expect(lineStateForScreenRow(presenter, 6).decorationClasses).toEqual ['b'] - expect(lineStateForScreenRow(presenter, 7).decorationClasses).toBeNull() - - waitsForStateToUpdate presenter, -> decoration1.destroy() - runs -> - expect(lineStateForScreenRow(presenter, 2).decorationClasses).toBeNull() - expect(lineStateForScreenRow(presenter, 3).decorationClasses).toBeNull() - expect(lineStateForScreenRow(presenter, 4).decorationClasses).toEqual ['b'] - expect(lineStateForScreenRow(presenter, 5).decorationClasses).toEqual ['b'] - expect(lineStateForScreenRow(presenter, 6).decorationClasses).toEqual ['b'] - expect(lineStateForScreenRow(presenter, 7).decorationClasses).toBeNull() - - waitsForStateToUpdate presenter, -> marker2.destroy() - runs -> - expect(lineStateForScreenRow(presenter, 2).decorationClasses).toBeNull() - expect(lineStateForScreenRow(presenter, 3).decorationClasses).toBeNull() - expect(lineStateForScreenRow(presenter, 4).decorationClasses).toBeNull() - expect(lineStateForScreenRow(presenter, 5).decorationClasses).toBeNull() - expect(lineStateForScreenRow(presenter, 6).decorationClasses).toBeNull() - expect(lineStateForScreenRow(presenter, 7).decorationClasses).toBeNull() - - it "honors the 'onlyEmpty' option on line decorations", -> - presenter = buildPresenter() - marker = editor.markBufferRange([[4, 0], [6, 1]]) - decoration = editor.decorateMarker(marker, type: 'line', class: 'a', onlyEmpty: true) - - expect(lineStateForScreenRow(presenter, 4).decorationClasses).toBeNull() - expect(lineStateForScreenRow(presenter, 5).decorationClasses).toBeNull() - expect(lineStateForScreenRow(presenter, 6).decorationClasses).toBeNull() - - waitsForStateToUpdate presenter, -> marker.clearTail() - - runs -> - expect(lineStateForScreenRow(presenter, 4).decorationClasses).toBeNull() - expect(lineStateForScreenRow(presenter, 5).decorationClasses).toBeNull() - expect(lineStateForScreenRow(presenter, 6).decorationClasses).toEqual ['a'] - - it "honors the 'onlyNonEmpty' option on line decorations", -> - presenter = buildPresenter() - marker = editor.markBufferRange([[4, 0], [6, 2]]) - decoration = editor.decorateMarker(marker, type: 'line', class: 'a', onlyNonEmpty: true) - - expect(lineStateForScreenRow(presenter, 4).decorationClasses).toEqual ['a'] - expect(lineStateForScreenRow(presenter, 5).decorationClasses).toEqual ['a'] - expect(lineStateForScreenRow(presenter, 6).decorationClasses).toEqual ['a'] - - waitsForStateToUpdate presenter, -> marker.clearTail() - - runs -> - expect(lineStateForScreenRow(presenter, 6).decorationClasses).toBeNull() - - it "honors the 'onlyHead' option on line decorations", -> - presenter = buildPresenter() - waitsForStateToUpdate presenter, -> - marker = editor.markBufferRange([[4, 0], [6, 2]]) - editor.decorateMarker(marker, type: 'line', class: 'a', onlyHead: true) - - runs -> - expect(lineStateForScreenRow(presenter, 4).decorationClasses).toBeNull() - expect(lineStateForScreenRow(presenter, 5).decorationClasses).toBeNull() - expect(lineStateForScreenRow(presenter, 6).decorationClasses).toEqual ['a'] - - it "does not decorate the last line of a non-empty line decoration range if it ends at column 0", -> - presenter = buildPresenter() - waitsForStateToUpdate presenter, -> - marker = editor.markBufferRange([[4, 0], [6, 0]]) - editor.decorateMarker(marker, type: 'line', class: 'a') - - runs -> - expect(lineStateForScreenRow(presenter, 4).decorationClasses).toEqual ['a'] - expect(lineStateForScreenRow(presenter, 5).decorationClasses).toEqual ['a'] - expect(lineStateForScreenRow(presenter, 6).decorationClasses).toBeNull() - - it "does not apply line decorations to mini editors", -> - editor.setMini(true) - presenter = buildPresenter(explicitHeight: 10) - - waitsForStateToUpdate presenter, -> - marker = editor.markBufferRange([[0, 0], [0, 0]]) - decoration = editor.decorateMarker(marker, type: 'line', class: 'a') - - runs -> - expect(lineStateForScreenRow(presenter, 0).decorationClasses).toBeNull() - - expectStateUpdate presenter, -> editor.setMini(false) - expect(lineStateForScreenRow(presenter, 0).decorationClasses).toEqual ['cursor-line', 'a'] - - expectStateUpdate presenter, -> editor.setMini(true) - expect(lineStateForScreenRow(presenter, 0).decorationClasses).toBeNull() - - it "only applies decorations to screen rows that are spanned by their marker when lines are soft-wrapped", -> - editor.setText("a line that wraps, ok") - editor.update({softWrapped: true}) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(16) - marker = editor.markBufferRange([[0, 0], [0, 2]]) - editor.decorateMarker(marker, type: 'line', class: 'a') - presenter = buildPresenter(explicitHeight: 10) - - expect(lineStateForScreenRow(presenter, 0).decorationClasses).toContain 'a' - expect(lineStateForScreenRow(presenter, 1).decorationClasses).toBeNull() - - waitsForStateToUpdate presenter, -> - marker.setBufferRange([[0, 0], [0, Infinity]]) - - runs -> - expect(lineStateForScreenRow(presenter, 0).decorationClasses).toContain 'a' - expect(lineStateForScreenRow(presenter, 1).decorationClasses).toContain 'a' - - describe ".cursors", -> - stateForCursor = (presenter, cursorIndex) -> - getState(presenter).content.cursors[presenter.model.getCursors()[cursorIndex].id] - - it "contains pixelRects for empty selections that are visible on screen", -> - editor.update({showCursorOnSelection: false}) - editor.setSelectedBufferRanges([ - [[1, 2], [1, 2]], - [[2, 4], [2, 4]], - [[3, 4], [3, 5]] - [[5, 12], [5, 12]], - [[8, 4], [8, 4]] - ]) - presenter = buildPresenter(explicitHeight: 30, scrollTop: 20) - - expect(stateForCursor(presenter, 0)).toBeUndefined() - expect(stateForCursor(presenter, 1)).toEqual {top: 0, left: 4 * 10, width: 10, height: 10} - expect(stateForCursor(presenter, 2)).toBeUndefined() - expect(stateForCursor(presenter, 3)).toEqual {top: 5 * 10 - 20, left: 12 * 10, width: 10, height: 10} - expect(stateForCursor(presenter, 4)).toBeUndefined() - - it "is empty until all of the required measurements are assigned", -> - presenter = buildPresenterWithoutMeasurements() - expect(getState(presenter).content.cursors).toEqual({}) - - presenter.setExplicitHeight(25) - expect(getState(presenter).content.cursors).toEqual({}) - - presenter.setLineHeight(10) - expect(getState(presenter).content.cursors).toEqual({}) - - presenter.setScrollTop(0) - expect(getState(presenter).content.cursors).toEqual({}) - - presenter.setBaseCharacterWidth(8) - expect(getState(presenter).content.cursors).toEqual({}) - - presenter.setBoundingClientRect(top: 0, left: 0, width: 500, height: 130) - expect(getState(presenter).content.cursors).toEqual({}) - - presenter.setWindowSize(500, 130) - expect(getState(presenter).content.cursors).toEqual({}) - - presenter.setVerticalScrollbarWidth(10) - expect(getState(presenter).content.cursors).toEqual({}) - - presenter.setHorizontalScrollbarHeight(10) - expect(getState(presenter).content.cursors).not.toEqual({}) - - it "updates when block decorations change", -> - editor.update({showCursorOnSelection: false}) - editor.setSelectedBufferRanges([ - [[1, 2], [1, 2]], - [[2, 4], [2, 4]], - [[3, 4], [3, 5]] - [[5, 12], [5, 12]], - [[8, 4], [8, 4]] - ]) - presenter = buildPresenter(explicitHeight: 80, scrollTop: 0) - - expect(stateForCursor(presenter, 0)).toEqual {top: 10, left: 2 * 10, width: 10, height: 10} - expect(stateForCursor(presenter, 1)).toEqual {top: 20, left: 4 * 10, width: 10, height: 10} - expect(stateForCursor(presenter, 2)).toBeUndefined() - expect(stateForCursor(presenter, 3)).toEqual {top: 5 * 10, left: 12 * 10, width: 10, height: 10} - expect(stateForCursor(presenter, 4)).toEqual {top: 8 * 10, left: 4 * 10, width: 10, height: 10} - - blockDecoration1 = addBlockDecorationBeforeScreenRow(0) - blockDecoration2 = addBlockDecorationAfterScreenRow(1) - - waitsForStateToUpdate presenter, -> - presenter.setBlockDecorationDimensions(blockDecoration1, 0, 30) - presenter.setBlockDecorationDimensions(blockDecoration2, 0, 10) - - runs -> - expect(stateForCursor(presenter, 0)).toEqual {top: 1 * 10 + 30, left: 2 * 10, width: 10, height: 10} - expect(stateForCursor(presenter, 1)).toEqual {top: 2 * 10 + 30 + 10, left: 4 * 10, width: 10, height: 10} - expect(stateForCursor(presenter, 2)).toBeUndefined() - expect(stateForCursor(presenter, 3)).toBeUndefined() - expect(stateForCursor(presenter, 4)).toBeUndefined() - - waitsForStateToUpdate presenter, -> - blockDecoration2.destroy() - editor.setCursorBufferPosition([0, 0]) - editor.insertNewline() - editor.setCursorBufferPosition([0, 0]) - - runs -> - expect(stateForCursor(presenter, 0)).toEqual {top: 0, left: 0, width: 10, height: 10} - - it "considers block decorations to be before a line by default", -> - editor.setCursorScreenPosition([4, 0]) - blockDecoration = editor.decorateMarker(editor.markScreenPosition([4, 0]), {type: "block"}) - presenter = buildPresenter() - presenter.setBlockDecorationDimensions(blockDecoration, 0, 6) - - expect(stateForCursor(presenter, 0)).toEqual {top: 4 * 10 + 6, left: 0, width: 10, height: 10} - - it "updates when ::scrollTop changes", -> - editor.setSelectedBufferRanges([ - [[1, 2], [1, 2]], - [[2, 4], [2, 4]], - [[3, 4], [3, 5]] - [[5, 12], [5, 12]], - [[8, 4], [8, 4]] - ]) - presenter = buildPresenter(explicitHeight: 30, scrollTop: 20) - - expectStateUpdate presenter, -> presenter.setScrollTop(5 * 10) - expect(stateForCursor(presenter, 0)).toBeUndefined() - expect(stateForCursor(presenter, 1)).toBeUndefined() - expect(stateForCursor(presenter, 2)).toBeUndefined() - expect(stateForCursor(presenter, 3)).toEqual {top: 0, left: 12 * 10, width: 10, height: 10} - expect(stateForCursor(presenter, 4)).toEqual {top: 8 * 10 - 50, left: 4 * 10, width: 10, height: 10} - - it "updates when ::scrollTop changes after the model was changed", -> - editor.setCursorBufferPosition([8, 22]) - presenter = buildPresenter(explicitHeight: 50, scrollTop: 10 * 8) - - expect(stateForCursor(presenter, 0)).toEqual {top: 0, left: 10 * 22, width: 10, height: 10} - - expectStateUpdate presenter, -> - editor.getBuffer().deleteRow(12) - editor.getBuffer().deleteRow(11) - editor.getBuffer().deleteRow(10) - - expect(stateForCursor(presenter, 0)).toEqual {top: 20, left: 10 * 22, width: 10, height: 10} - - it "updates when ::explicitHeight changes", -> - editor.update({showCursorOnSelection: false}) - editor.setSelectedBufferRanges([ - [[1, 2], [1, 2]], - [[2, 4], [2, 4]], - [[3, 4], [3, 5]] - [[5, 12], [5, 12]], - [[8, 4], [8, 4]] - ]) - presenter = buildPresenter(explicitHeight: 20, scrollTop: 20) - - expectStateUpdate presenter, -> presenter.setExplicitHeight(30) - expect(stateForCursor(presenter, 0)).toBeUndefined() - expect(stateForCursor(presenter, 1)).toEqual {top: 0, left: 4 * 10, width: 10, height: 10} - expect(stateForCursor(presenter, 2)).toBeUndefined() - expect(stateForCursor(presenter, 3)).toEqual {top: 5 * 10 - 20, left: 12 * 10, width: 10, height: 10} - expect(stateForCursor(presenter, 4)).toBeUndefined() - - it "updates when ::lineHeight changes", -> - editor.setSelectedBufferRanges([ - [[1, 2], [1, 2]], - [[2, 4], [2, 4]], - [[3, 4], [3, 5]] - [[5, 12], [5, 12]], - [[8, 4], [8, 4]] - ]) - presenter = buildPresenter(explicitHeight: 20, scrollTop: 20) - - expectStateUpdate presenter, -> presenter.setLineHeight(5) - expect(stateForCursor(presenter, 0)).toBeUndefined() - expect(stateForCursor(presenter, 1)).toBeUndefined() - expect(stateForCursor(presenter, 2)).toBeUndefined() - expect(stateForCursor(presenter, 3)).toEqual {top: 5, left: 12 * 10, width: 10, height: 5} - expect(stateForCursor(presenter, 4)).toEqual {top: 8 * 5 - 20, left: 4 * 10, width: 10, height: 5} - - it "updates when scoped character widths change", -> - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - runs -> - editor.setGrammar(atom.grammars.grammarForScopeName('source.js')) - editor.setCursorBufferPosition([1, 4]) - presenter = buildPresenter(explicitHeight: 20) - - expectStateUpdate presenter, -> - presenter.getLinesYardstick().setScopedCharacterWidth(['syntax--source.syntax--js', 'syntax--storage.syntax--type.syntax--var.syntax--js'], 'v', 20) - presenter.measurementsChanged() - expect(stateForCursor(presenter, 0)).toEqual {top: 1 * 10, left: (3 * 10) + 20, width: 10, height: 10} - - expectStateUpdate presenter, -> - presenter.getLinesYardstick().setScopedCharacterWidth(['syntax--source.syntax--js', 'syntax--storage.syntax--type.syntax--var.syntax--js'], 'r', 20) - presenter.measurementsChanged() - expect(stateForCursor(presenter, 0)).toEqual {top: 1 * 10, left: (3 * 10) + 20, width: 20, height: 10} - - it "updates when cursors are added, moved, hidden, shown, or destroyed", -> - editor.update({showCursorOnSelection: false}) - editor.setSelectedBufferRanges([ - [[1, 2], [1, 2]], - [[3, 4], [3, 5]] - ]) - presenter = buildPresenter(explicitHeight: 20, scrollTop: 20) - - # moving into view - expect(stateForCursor(presenter, 0)).toBeUndefined() - editor.getCursors()[0].setBufferPosition([2, 4]) - expect(stateForCursor(presenter, 0)).toEqual {top: 0, left: 4 * 10, width: 10, height: 10} - - # showing - expectStateUpdate presenter, -> editor.getSelections()[1].clear() - expect(stateForCursor(presenter, 1)).toEqual {top: 0, left: 5 * 10, width: 10, height: 10} - - # hiding - expectStateUpdate presenter, -> editor.getSelections()[1].setBufferRange([[3, 4], [3, 5]]) - expect(stateForCursor(presenter, 1)).toBeUndefined() - - # moving out of view - expectStateUpdate presenter, -> editor.getCursors()[0].setBufferPosition([10, 4]) - expect(stateForCursor(presenter, 0)).toBeUndefined() - - # adding - expectStateUpdate presenter, -> editor.addCursorAtBufferPosition([4, 4]) - expect(stateForCursor(presenter, 2)).toEqual {top: 0, left: 4 * 10, width: 10, height: 10} - - # moving added cursor - expectStateUpdate presenter, -> editor.getCursors()[2].setBufferPosition([4, 6]) - expect(stateForCursor(presenter, 2)).toEqual {top: 0, left: 6 * 10, width: 10, height: 10} - - # destroying - destroyedCursor = editor.getCursors()[2] - expectStateUpdate presenter, -> destroyedCursor.destroy() - expect(getState(presenter).content.cursors[destroyedCursor.id]).toBeUndefined() - - it "makes cursors as wide as the ::baseCharacterWidth if they're at the end of a line", -> - editor.setCursorBufferPosition([1, Infinity]) - presenter = buildPresenter(explicitHeight: 20, scrollTop: 0) - expect(stateForCursor(presenter, 0).width).toBe 10 - - describe ".cursorsVisible", -> - it "alternates between true and false twice per ::cursorBlinkPeriod when the editor is focused", -> - cursorBlinkPeriod = 100 - cursorBlinkResumeDelay = 200 - presenter = buildPresenter({cursorBlinkPeriod, cursorBlinkResumeDelay}) - presenter.setFocused(true) - - expect(getState(presenter).content.cursorsVisible).toBe true - expectStateUpdate presenter, -> advanceClock(cursorBlinkPeriod / 2) - expect(getState(presenter).content.cursorsVisible).toBe false - expectStateUpdate presenter, -> advanceClock(cursorBlinkPeriod / 2) - expect(getState(presenter).content.cursorsVisible).toBe true - expectStateUpdate presenter, -> advanceClock(cursorBlinkPeriod / 2) - expect(getState(presenter).content.cursorsVisible).toBe false - expectStateUpdate presenter, -> advanceClock(cursorBlinkPeriod / 2) - expect(getState(presenter).content.cursorsVisible).toBe true - - expectStateUpdate presenter, -> presenter.setFocused(false) - expect(getState(presenter).content.cursorsVisible).toBe false - advanceClock(cursorBlinkPeriod / 2) - expect(getState(presenter).content.cursorsVisible).toBe false - advanceClock(cursorBlinkPeriod / 2) - expect(getState(presenter).content.cursorsVisible).toBe false - - expectStateUpdate presenter, -> presenter.setFocused(true) - expect(getState(presenter).content.cursorsVisible).toBe true - expectStateUpdate presenter, -> advanceClock(cursorBlinkPeriod / 2) - expect(getState(presenter).content.cursorsVisible).toBe false - - it "stops alternating for ::cursorBlinkResumeDelay when a cursor moves or a cursor is added", -> - cursorBlinkPeriod = 100 - cursorBlinkResumeDelay = 200 - presenter = buildPresenter({cursorBlinkPeriod, cursorBlinkResumeDelay}) - presenter.setFocused(true) - - expect(getState(presenter).content.cursorsVisible).toBe true - expectStateUpdate presenter, -> advanceClock(cursorBlinkPeriod / 2) - expect(getState(presenter).content.cursorsVisible).toBe false - - expectStateUpdate presenter, -> editor.moveRight() - expect(getState(presenter).content.cursorsVisible).toBe true - - expectStateUpdate presenter, -> - advanceClock(cursorBlinkResumeDelay) - advanceClock(cursorBlinkPeriod / 2) - - expect(getState(presenter).content.cursorsVisible).toBe false - expectStateUpdate presenter, -> advanceClock(cursorBlinkPeriod / 2) - expect(getState(presenter).content.cursorsVisible).toBe true - expectStateUpdate presenter, -> advanceClock(cursorBlinkPeriod / 2) - expect(getState(presenter).content.cursorsVisible).toBe false - - expectStateUpdate presenter, -> editor.addCursorAtBufferPosition([1, 0]) - expect(getState(presenter).content.cursorsVisible).toBe true - - expectStateUpdate presenter, -> - advanceClock(cursorBlinkResumeDelay) - advanceClock(cursorBlinkPeriod / 2) - expect(getState(presenter).content.cursorsVisible).toBe false - - describe ".highlights", -> - expectUndefinedStateForHighlight = (presenter, decoration) -> - for tileId of getState(presenter).content.tiles - state = stateForHighlightInTile(presenter, decoration, tileId) - expect(state).toBeUndefined() - - stateForHighlightInTile = (presenter, decoration, tile) -> - getState(presenter).content.tiles[tile]?.highlights[decoration.id] - - stateForSelectionInTile = (presenter, selectionIndex, tile) -> - selection = presenter.model.getSelections()[selectionIndex] - stateForHighlightInTile(presenter, selection.decoration, tile) - - expectUndefinedStateForSelection = (presenter, selectionIndex) -> - for tileId of getState(presenter).content.tiles - state = stateForSelectionInTile(presenter, selectionIndex, tileId) - expect(state).toBeUndefined() - - it "contains states for highlights that are visible on screen", -> - # off-screen above - marker1 = editor.markBufferRange([[0, 0], [1, 0]]) - highlight1 = editor.decorateMarker(marker1, type: 'highlight', class: 'a') - - # partially off-screen above, 1 of 2 regions on screen - marker2 = editor.markBufferRange([[1, 6], [2, 6]]) - highlight2 = editor.decorateMarker(marker2, type: 'highlight', class: 'b') - - # partially off-screen above, 2 of 3 regions on screen - marker3 = editor.markBufferRange([[0, 6], [3, 6]]) - highlight3 = editor.decorateMarker(marker3, type: 'highlight', class: 'c') - - # on-screen, spans over 2 tiles - marker4 = editor.markBufferRange([[2, 6], [4, 6]]) - highlight4 = editor.decorateMarker(marker4, type: 'highlight', class: 'd') - - # partially off-screen below, spans over 3 tiles, 2 of 3 regions on screen - marker5 = editor.markBufferRange([[3, 6], [6, 6]]) - highlight5 = editor.decorateMarker(marker5, type: 'highlight', class: 'e') - - # partially off-screen below, 1 of 3 regions on screen - marker6 = editor.markBufferRange([[5, 6], [7, 6]]) - highlight6 = editor.decorateMarker(marker6, type: 'highlight', class: 'f') - - # off-screen below - marker7 = editor.markBufferRange([[6, 6], [7, 6]]) - highlight7 = editor.decorateMarker(marker7, type: 'highlight', class: 'g') - - # on-screen, empty - marker8 = editor.markBufferRange([[2, 2], [2, 2]]) - highlight8 = editor.decorateMarker(marker8, type: 'highlight', class: 'h') - - # partially off-screen above, empty - marker9 = editor.markBufferRange([[0, 0], [2, 0]], invalidate: 'touch') - highlight9 = editor.decorateMarker(marker9, type: 'highlight', class: 'h') - - presenter = buildPresenter(explicitHeight: 30, scrollTop: 20, tileSize: 2) - - expectUndefinedStateForHighlight(presenter, highlight1) - - expectValues stateForHighlightInTile(presenter, highlight2, 2), { - class: 'b' - regions: [ - {top: 0, left: 0 * 10, width: 6 * 10, height: 1 * 10} - ] - } - - expectValues stateForHighlightInTile(presenter, highlight3, 2), { - class: 'c' - regions: [ - {top: 0, left: 0 * 10, right: 0, height: 1 * 10} - {top: 10, left: 0 * 10, width: 6 * 10, height: 1 * 10} - ] - } - - expectValues stateForHighlightInTile(presenter, highlight4, 2), { - class: 'd' - regions: [ - {top: 0, left: 6 * 10, right: 0, height: 1 * 10} - {top: 10, left: 0, right: 0, height: 1 * 10} - ] - } - expectValues stateForHighlightInTile(presenter, highlight4, 4), { - class: 'd' - regions: [ - {top: 0, left: 0, width: 60, height: 1 * 10} - ] - } - - expectValues stateForHighlightInTile(presenter, highlight5, 2), { - class: 'e' - regions: [ - {top: 10, left: 6 * 10, right: 0, height: 1 * 10} - ] - } - - expectValues stateForHighlightInTile(presenter, highlight5, 4), { - class: 'e' - regions: [ - {top: 0, left: 0, right: 0, height: 1 * 10} - {top: 10, left: 0, right: 0, height: 1 * 10} - ] - } - - expect(stateForHighlightInTile(presenter, highlight5, 6)).toBeUndefined() - - expectValues stateForHighlightInTile(presenter, highlight6, 4), { - class: 'f' - regions: [ - {top: 10, left: 6 * 10, right: 0, height: 1 * 10} - ] - } - - expect(stateForHighlightInTile(presenter, highlight6, 6)).toBeUndefined() - - expectUndefinedStateForHighlight(presenter, highlight7) - expectUndefinedStateForHighlight(presenter, highlight8) - expectUndefinedStateForHighlight(presenter, highlight9) - - it "is empty until all of the required measurements are assigned", -> - editor.setSelectedBufferRanges([ - [[0, 2], [2, 4]], - ]) - - presenter = buildPresenterWithoutMeasurements(tileSize: 2) - for tileId, tileState of getState(presenter).content.tiles - expect(tileState.highlights).toEqual({}) - - presenter.setExplicitHeight(25) - for tileId, tileState of getState(presenter).content.tiles - expect(tileState.highlights).toEqual({}) - - presenter.setLineHeight(10) - for tileId, tileState of getState(presenter).content.tiles - expect(tileState.highlights).toEqual({}) - - presenter.setScrollTop(0) - for tileId, tileState of getState(presenter).content.tiles - expect(tileState.highlights).toEqual({}) - - presenter.setBaseCharacterWidth(8) - assignedAnyHighlight = false - for tileId, tileState of getState(presenter).content.tiles - assignedAnyHighlight ||= _.isEqual(tileState.highlights, {}) - - expect(assignedAnyHighlight).toBe(true) - - it "does not include highlights for invalid markers", -> - marker = editor.markBufferRange([[2, 2], [2, 4]], invalidate: 'touch') - highlight = editor.decorateMarker(marker, type: 'highlight', class: 'h') - - presenter = buildPresenter(explicitHeight: 30, scrollTop: 20, tileSize: 2) - - expect(stateForHighlightInTile(presenter, highlight, 2)).toBeDefined() - - expectStateUpdate presenter, -> editor.getBuffer().insert([2, 2], "stuff") - - expectUndefinedStateForHighlight(presenter, highlight) - - it "does not include highlights that end before the first visible row", -> - editor.setText("Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat.") - editor.update({softWrapped: true}) - editor.setWidth(100, true) - editor.setDefaultCharWidth(10) - - marker = editor.markBufferRange([[0, 0], [0, 4]], invalidate: 'never') - highlight = editor.decorateMarker(marker, type: 'highlight', class: 'a') - presenter = buildPresenter(explicitHeight: 30, scrollTop: 10, tileSize: 2) - - expect(stateForHighlightInTile(presenter, highlight, 0)).toBeUndefined() - - it "handles highlights that extend to the left of the visible area (regression)", -> - editor.setSelectedBufferRanges([ - [[0, 2], [1, 4]], - ]) - - presenter = buildPresenter(explicitHeight: 20, scrollLeft: 0, tileSize: 2) - expectValues stateForSelectionInTile(presenter, 0, 0), { - regions: [ - {top: 0 * 10, height: 10, left: 2 * 10, right: 0 * 10}, - {top: 1 * 10, height: 10, left: 0 * 10, width: 4 * 10} - ] - } - - presenter = buildPresenter(explicitHeight: 20, scrollLeft: 20, tileSize: 2) - expectValues stateForSelectionInTile(presenter, 0, 0), { - regions: [ - {top: 0 * 10, height: 10, left: 2 * 10, right: 0 * 10}, - {top: 1 * 10, height: 10, left: 0 * 10, width: 4 * 10} - ] - } - - it "updates when ::scrollTop changes", -> - editor.setSelectedBufferRanges([ - [[6, 2], [6, 4]], - ]) - - presenter = buildPresenter(explicitHeight: 30, scrollTop: 20, tileSize: 2) - - expectUndefinedStateForSelection(presenter, 0) - expectStateUpdate presenter, -> presenter.setScrollTop(5 * 10) - expect(stateForSelectionInTile(presenter, 0, 6)).toBeDefined() - expectStateUpdate presenter, -> presenter.setScrollTop(2 * 10) - expectUndefinedStateForSelection(presenter, 0) - - it "updates when ::explicitHeight changes", -> - editor.setSelectedBufferRanges([ - [[6, 2], [6, 4]], - ]) - - presenter = buildPresenter(explicitHeight: 20, scrollTop: 20, tileSize: 2) - - expectUndefinedStateForSelection(presenter, 0) - expectStateUpdate presenter, -> presenter.setExplicitHeight(60) - expect(stateForSelectionInTile(presenter, 0, 6)).toBeDefined() - expectStateUpdate presenter, -> presenter.setExplicitHeight(20) - expectUndefinedStateForSelection(presenter, 0) - - it "updates when ::lineHeight changes", -> - editor.setSelectedBufferRanges([ - [[2, 2], [2, 4]], - [[3, 4], [3, 6]], - ]) - - presenter = buildPresenter(explicitHeight: 20, scrollTop: 0, tileSize: 2) - - expectValues stateForSelectionInTile(presenter, 0, 2), { - regions: [ - {top: 0, left: 2 * 10, width: 2 * 10, height: 10} - ] - } - expectUndefinedStateForSelection(presenter, 1) - - expectStateUpdate presenter, -> presenter.setLineHeight(5) - - expectValues stateForSelectionInTile(presenter, 0, 2), { - regions: [ - {top: 0, left: 2 * 10, width: 2 * 10, height: 5} - ] - } - - expectValues stateForSelectionInTile(presenter, 1, 2), { - regions: [ - {top: 5, left: 4 * 10, width: 2 * 10, height: 5} - ] - } - - it "updates when scoped character widths change", -> - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - runs -> - editor.setGrammar(atom.grammars.grammarForScopeName('source.js')) - editor.setSelectedBufferRanges([ - [[2, 4], [2, 6]], - ]) - - presenter = buildPresenter(explicitHeight: 20, scrollTop: 0, tileSize: 2) - - expectValues stateForSelectionInTile(presenter, 0, 2), { - regions: [{top: 0, left: 4 * 10, width: 2 * 10, height: 10}] - } - expectStateUpdate presenter, -> - presenter.getLinesYardstick().setScopedCharacterWidth(['syntax--source.syntax--js', 'syntax--keyword.syntax--control.syntax--js'], 'i', 20) - presenter.measurementsChanged() - expectValues stateForSelectionInTile(presenter, 0, 2), { - regions: [{top: 0, left: 4 * 10, width: 20 + 10, height: 10}] - } - - it "updates when highlight decorations are added, moved, hidden, shown, or destroyed", -> - editor.setSelectedBufferRanges([ - [[1, 2], [1, 4]], - [[3, 4], [3, 6]] - ]) - presenter = buildPresenter(explicitHeight: 20, scrollTop: 0, tileSize: 2) - - expectValues stateForSelectionInTile(presenter, 0, 0), { - regions: [{top: 10, left: 2 * 10, width: 2 * 10, height: 10}] - } - expectUndefinedStateForSelection(presenter, 1) - - # moving into view - waitsForStateToUpdate presenter, -> editor.getSelections()[1].setBufferRange([[2, 4], [2, 6]], autoscroll: false) - runs -> - expectValues stateForSelectionInTile(presenter, 1, 2), { - regions: [{top: 0, left: 4 * 10, width: 2 * 10, height: 10}] - } - - # becoming empty - runs -> - editor.getSelections()[1].clear(autoscroll: false) - waitsForStateToUpdate presenter - runs -> - expectUndefinedStateForSelection(presenter, 1) - - # becoming non-empty - runs -> - editor.getSelections()[1].setBufferRange([[2, 4], [2, 6]], autoscroll: false) - waitsForStateToUpdate presenter - runs -> - expectValues stateForSelectionInTile(presenter, 1, 2), { - regions: [{top: 0, left: 4 * 10, width: 2 * 10, height: 10}] - } - - # moving out of view - runs -> - editor.getSelections()[1].setBufferRange([[3, 4], [3, 6]], autoscroll: false) - waitsForStateToUpdate presenter - runs -> - expectUndefinedStateForSelection(presenter, 1) - - # adding - runs -> editor.addSelectionForBufferRange([[1, 4], [1, 6]], autoscroll: false) - waitsForStateToUpdate presenter - runs -> - expectValues stateForSelectionInTile(presenter, 2, 0), { - regions: [{top: 10, left: 4 * 10, width: 2 * 10, height: 10}] - } - - # moving added selection - runs -> - editor.getSelections()[2].setBufferRange([[1, 4], [1, 8]], autoscroll: false) - waitsForStateToUpdate presenter - - [destroyedSelection, destroyedDecoration] = [] - runs -> - expectValues stateForSelectionInTile(presenter, 2, 0), { - regions: [{top: 10, left: 4 * 10, width: 4 * 10, height: 10}] - } - - # destroying - destroyedSelection = editor.getSelections()[2] - destroyedDecoration = destroyedSelection.decoration - - waitsForStateToUpdate presenter, -> destroyedSelection.destroy() - runs -> - expectUndefinedStateForHighlight(presenter, destroyedDecoration) - - it "updates when highlight decorations' properties are updated", -> - marker = editor.markBufferPosition([2, 2]) - highlight = editor.decorateMarker(marker, type: 'highlight', class: 'a') - - presenter = buildPresenter(explicitHeight: 30, scrollTop: 20, tileSize: 2) - - expectUndefinedStateForHighlight(presenter, highlight) - - waitsForStateToUpdate presenter, -> - marker.setBufferRange([[2, 2], [2, 4]]) - highlight.setProperties(class: 'b', type: 'highlight') - - runs -> - expectValues stateForHighlightInTile(presenter, highlight, 2), {class: 'b'} - - it "increments the .flashCount and sets the .flashClass and .flashDuration when the highlight model flashes", -> - presenter = buildPresenter(explicitHeight: 30, scrollTop: 20, tileSize: 2) - - marker = editor.markBufferPosition([2, 2]) - highlight = null - waitsForStateToUpdate presenter, -> - highlight = editor.decorateMarker(marker, type: 'highlight', class: 'a') - marker.setBufferRange([[2, 2], [5, 2]]) - highlight.flash('b', 500) - runs -> - expectValues stateForHighlightInTile(presenter, highlight, 2), { - needsFlash: true - flashClass: 'b' - flashDuration: 500 - flashCount: 1 - } - expectValues stateForHighlightInTile(presenter, highlight, 4), { - needsFlash: true - flashClass: 'b' - flashDuration: 500 - flashCount: 1 - } - - waitsForStateToUpdate presenter, -> highlight.flash('c', 600) - runs -> - expectValues stateForHighlightInTile(presenter, highlight, 2), { - needsFlash: true - flashClass: 'c' - flashDuration: 600 - flashCount: 2 - } - expectValues stateForHighlightInTile(presenter, highlight, 4), { - needsFlash: true - flashClass: 'c' - flashDuration: 600 - flashCount: 2 - } - - waitsForStateToUpdate presenter, -> marker.setBufferRange([[2, 2], [6, 2]]) - runs -> - expectValues stateForHighlightInTile(presenter, highlight, 2), {needsFlash: false} - expectValues stateForHighlightInTile(presenter, highlight, 4), {needsFlash: false} - - describe ".offScreenBlockDecorations", -> - stateForOffScreenBlockDecoration = (presenter, decoration) -> - getState(presenter).content.offScreenBlockDecorations[decoration.id] - - it "contains state for off-screen unmeasured block decorations, both initially and when they are updated or destroyed", -> - item = {} - blockDecoration1 = addBlockDecorationBeforeScreenRow(0, item) - blockDecoration2 = addBlockDecorationBeforeScreenRow(4, item) - blockDecoration3 = addBlockDecorationBeforeScreenRow(4, item) - blockDecoration4 = addBlockDecorationBeforeScreenRow(10, item) - presenter = buildPresenter(explicitHeight: 30, lineHeight: 10, tileSize: 2, scrollTop: 0) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration1)).toBeUndefined() - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration2)).toBeUndefined() - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration3)).toBeUndefined() - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration4)).toBe(blockDecoration4) - - presenter.setBlockDecorationDimensions(blockDecoration1, 0, 10) - presenter.setBlockDecorationDimensions(blockDecoration4, 0, 20) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration1)).toBeUndefined() - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration2)).toBe(blockDecoration2) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration3)).toBe(blockDecoration3) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration4)).toBeUndefined() - - presenter.invalidateBlockDecorationDimensions(blockDecoration1) - presenter.invalidateBlockDecorationDimensions(blockDecoration4) - presenter.setBlockDecorationDimensions(blockDecoration2, 0, 10) - presenter.setBlockDecorationDimensions(blockDecoration3, 0, 10) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration1)).toBeUndefined() - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration2)).toBeUndefined() - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration3)).toBeUndefined() - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration4)).toBe(blockDecoration4) - - blockDecoration4.destroy() - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration1)).toBeUndefined() - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration2)).toBeUndefined() - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration3)).toBeUndefined() - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration4)).toBeUndefined() - - it "contains state for off-screen block decorations that intersect a buffer change", -> - blockDecoration1 = addBlockDecorationBeforeScreenRow(9) - blockDecoration2 = addBlockDecorationBeforeScreenRow(10) - blockDecoration3 = addBlockDecorationBeforeScreenRow(11) - presenter = buildPresenter(explicitHeight: 30, lineHeight: 10, tileSize: 2, scrollTop: 0) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration1)).toBe(blockDecoration1) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration2)).toBe(blockDecoration2) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration3)).toBe(blockDecoration3) - - presenter.setBlockDecorationDimensions(blockDecoration1, 0, 10) - presenter.setBlockDecorationDimensions(blockDecoration2, 0, 10) - presenter.setBlockDecorationDimensions(blockDecoration3, 0, 10) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration1)).toBeUndefined() - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration2)).toBeUndefined() - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration3)).toBeUndefined() - - editor.setSelectedScreenRange([[10, 0], [12, 0]]) - editor.delete() - presenter.setScrollTop(0) # deleting the buffer causes the editor to autoscroll - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration1)).toBeUndefined() - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration2)).toBe(blockDecoration2) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration3)).toBe(blockDecoration3) - - it "contains state for all off-screen block decorations when content frame width, window size or bounding client rect change", -> - blockDecoration1 = addBlockDecorationBeforeScreenRow(10) - blockDecoration2 = addBlockDecorationBeforeScreenRow(11) - presenter = buildPresenter(explicitHeight: 30, lineHeight: 10, tileSize: 2, scrollTop: 0) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration1)).toBe(blockDecoration1) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration2)).toBe(blockDecoration2) - - presenter.setBlockDecorationDimensions(blockDecoration1, 0, 10) - presenter.setBlockDecorationDimensions(blockDecoration2, 0, 10) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration1)).toBeUndefined() - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration2)).toBeUndefined() - - presenter.setBoundingClientRect({top: 0, left: 0, width: 50, height: 30}) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration1)).toBe(blockDecoration1) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration2)).toBe(blockDecoration2) - - presenter.setBlockDecorationDimensions(blockDecoration1, 0, 20) - presenter.setBlockDecorationDimensions(blockDecoration2, 0, 20) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration1)).toBeUndefined() - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration2)).toBeUndefined() - - presenter.setContentFrameWidth(100) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration1)).toBe(blockDecoration1) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration2)).toBe(blockDecoration2) - - presenter.setBlockDecorationDimensions(blockDecoration1, 0, 20) - presenter.setBlockDecorationDimensions(blockDecoration2, 0, 20) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration1)).toBeUndefined() - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration2)).toBeUndefined() - - presenter.setWindowSize(100, 200) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration1)).toBe(blockDecoration1) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration2)).toBe(blockDecoration2) - - presenter.setBlockDecorationDimensions(blockDecoration1, 0, 20) - presenter.setBlockDecorationDimensions(blockDecoration2, 0, 20) - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration1)).toBeUndefined() - expect(stateForOffScreenBlockDecoration(presenter, blockDecoration2)).toBeUndefined() - - it "doesn't throw an error when setting the dimensions for a destroyed decoration", -> - blockDecoration = addBlockDecorationBeforeScreenRow(0) - presenter = buildPresenter() - blockDecoration.destroy() - presenter.setBlockDecorationDimensions(blockDecoration, 30, 30) - expect(getState(presenter).content.offScreenBlockDecorations).toEqual({}) - - describe ".overlays", -> - [item] = [] - stateForOverlay = (presenter, decoration) -> - getState(presenter).content.overlays[decoration.id] - - it "contains state for overlay decorations both initially and when their markers move", -> - marker = editor.addMarkerLayer(maintainHistory: true).markBufferPosition([2, 13], invalidate: 'touch') - decoration = editor.decorateMarker(marker, {type: 'overlay', item}) - presenter = buildPresenter(explicitHeight: 30, scrollTop: 20) - - # Initial state - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 3 * 10 - presenter.state.content.scrollTop, left: 13 * 10} - } - - # Change range - waitsForStateToUpdate presenter, -> marker.setBufferRange([[2, 13], [4, 6]]) - runs -> - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 5 * 10 - presenter.state.content.scrollTop, left: 6 * 10} - } - - # Valid -> invalid - waitsForStateToUpdate presenter, -> editor.getBuffer().insert([2, 14], 'x') - runs -> - expect(stateForOverlay(presenter, decoration)).toBeUndefined() - - # Invalid -> valid - waitsForStateToUpdate presenter, -> editor.undo() - runs -> - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 5 * 10 - presenter.state.content.scrollTop, left: 6 * 10} - } - - # Reverse direction - waitsForStateToUpdate presenter, -> marker.setBufferRange([[2, 13], [4, 6]], reversed: true) - runs -> - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 3 * 10 - presenter.state.content.scrollTop, left: 13 * 10} - } - - # Destroy - waitsForStateToUpdate presenter, -> decoration.destroy() - runs -> - expect(stateForOverlay(presenter, decoration)).toBeUndefined() - - # Add - decoration2 = null - waitsForStateToUpdate presenter, -> decoration2 = editor.decorateMarker(marker, {type: 'overlay', item}) - runs -> - expectValues stateForOverlay(presenter, decoration2), { - item: item - pixelPosition: {top: 3 * 10 - presenter.state.content.scrollTop, left: 13 * 10} - } - - it "updates when character widths changes", -> - scrollTop = 20 - marker = editor.markBufferPosition([2, 13], invalidate: 'touch') - decoration = editor.decorateMarker(marker, {type: 'overlay', item}) - presenter = buildPresenter({explicitHeight: 30, scrollTop}) - - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 3 * 10 - scrollTop, left: 13 * 10} - } - - expectStateUpdate presenter, -> presenter.setBaseCharacterWidth(5) - - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 3 * 10 - scrollTop, left: 13 * 5} - } - - it "updates when ::lineHeight changes", -> - scrollTop = 20 - marker = editor.markBufferPosition([2, 13], invalidate: 'touch') - decoration = editor.decorateMarker(marker, {type: 'overlay', item}) - presenter = buildPresenter({explicitHeight: 30, scrollTop}) - - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 3 * 10 - scrollTop, left: 13 * 10} - } - - expectStateUpdate presenter, -> presenter.setLineHeight(5) - - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 3 * 5 - scrollTop, left: 13 * 10} - } - - it "honors the 'position' option on overlay decorations", -> - scrollTop = 20 - marker = editor.markBufferRange([[2, 13], [4, 14]], invalidate: 'touch') - decoration = editor.decorateMarker(marker, {type: 'overlay', position: 'tail', item}) - presenter = buildPresenter({explicitHeight: 30, scrollTop}) - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 3 * 10 - scrollTop, left: 13 * 10} - } - - it "is empty until all of the required measurements are assigned", -> - marker = editor.markBufferRange([[2, 13], [4, 14]], invalidate: 'touch') - decoration = editor.decorateMarker(marker, {type: 'overlay', position: 'tail', item}) - - presenter = buildPresenterWithoutMeasurements() - expect(getState(presenter).content.overlays).toEqual({}) - - presenter.setBaseCharacterWidth(10) - expect(getState(presenter).content.overlays).toEqual({}) - - presenter.setLineHeight(10) - expect(getState(presenter).content.overlays).toEqual({}) - - presenter.setWindowSize(500, 100) - expect(getState(presenter).content.overlays).toEqual({}) - - presenter.setVerticalScrollbarWidth(10) - expect(getState(presenter).content.overlays).toEqual({}) - - presenter.setHorizontalScrollbarHeight(10) - expect(getState(presenter).content.overlays).toEqual({}) - - presenter.setBoundingClientRect({top: 0, left: 0, height: 100, width: 500}) - expect(getState(presenter).content.overlays).not.toEqual({}) - - describe "when the overlay has been measured", -> - [gutterWidth, windowWidth, windowHeight, itemWidth, itemHeight, contentMargin, boundingClientRect, contentFrameWidth] = [] - beforeEach -> - item = {} - gutterWidth = 5 * 10 # 5 chars wide - contentFrameWidth = 30 * 10 - windowWidth = gutterWidth + contentFrameWidth - windowHeight = 9 * 10 - - itemWidth = 4 * 10 - itemHeight = 4 * 10 - contentMargin = 0 - - boundingClientRect = - top: 0 - left: 0, - width: windowWidth - height: windowHeight - - it "slides horizontally left when near the right edge", -> - scrollLeft = 20 - marker = editor.markBufferPosition([0, 26], invalidate: 'never') - decoration = editor.decorateMarker(marker, {type: 'overlay', item}) - - presenter = buildPresenter({scrollLeft, windowWidth, windowHeight, contentFrameWidth, boundingClientRect, gutterWidth}) - expectStateUpdate presenter, -> - presenter.setOverlayDimensions(decoration.id, itemWidth, itemHeight, contentMargin) - - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 1 * 10, left: 26 * 10 + gutterWidth - scrollLeft} - } - - expectStateUpdate presenter, -> editor.insertText('abc', autoscroll: false) - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 1 * 10, left: windowWidth - itemWidth} - } - - expectStateUpdate presenter, -> editor.insertText('d', autoscroll: false) - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 1 * 10, left: windowWidth - itemWidth} - } - - it "flips vertically when near the bottom edge", -> - scrollTop = 10 - marker = editor.markBufferPosition([5, 0], invalidate: 'never') - decoration = editor.decorateMarker(marker, {type: 'overlay', item}) - - presenter = buildPresenter({scrollTop, windowWidth, windowHeight, contentFrameWidth, boundingClientRect, gutterWidth}) - expectStateUpdate presenter, -> - presenter.setOverlayDimensions(decoration.id, itemWidth, itemHeight, contentMargin) - - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 6 * 10 - scrollTop, left: gutterWidth} - } - - expectStateUpdate presenter, -> - editor.insertNewline(autoscroll: false) - - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 6 * 10 - scrollTop - itemHeight, left: gutterWidth} - } - - it "when avoidOverflow is false, does not move horizontally when overflowing the editor's scrollView horizontally", -> - scrollLeft = 20 - marker = editor.markBufferPosition([0, 26], invalidate: 'never') - decoration = editor.decorateMarker(marker, {type: 'overlay', item, avoidOverflow: false}) - - presenter = buildPresenter({scrollLeft, windowWidth, windowHeight, contentFrameWidth, boundingClientRect, gutterWidth}) - expectStateUpdate presenter, -> - presenter.setOverlayDimensions(decoration.id, itemWidth, itemHeight, contentMargin) - - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 1 * 10, left: 26 * 10 + gutterWidth - scrollLeft} - } - - expectStateUpdate presenter, -> editor.insertText('a', autoscroll: false) - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 1 * 10, left: 27 * 10 + gutterWidth - scrollLeft} - } - - it "when avoidOverflow is false, does not flip vertically when overflowing the editor's scrollView vertically", -> - scrollTop = 10 - marker = editor.markBufferPosition([5, 0], invalidate: 'never') - decoration = editor.decorateMarker(marker, {type: 'overlay', item, avoidOverflow: false}) - - presenter = buildPresenter({scrollTop, windowWidth, windowHeight, contentFrameWidth, boundingClientRect, gutterWidth}) - expectStateUpdate presenter, -> - presenter.setOverlayDimensions(decoration.id, itemWidth, itemHeight, contentMargin) - - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 6 * 10 - scrollTop, left: gutterWidth} - } - - expectStateUpdate presenter, -> - editor.insertNewline(autoscroll: false) - - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 7 * 10 - scrollTop, left: gutterWidth} - } - - describe "when the overlay item has a margin", -> - beforeEach -> - itemWidth = 12 * 10 - contentMargin = -(gutterWidth + 2 * 10) - - it "slides horizontally right when near the left edge with margin", -> - editor.setCursorBufferPosition([0, 3]) - cursor = editor.getLastCursor() - marker = cursor.marker - decoration = editor.decorateMarker(marker, {type: 'overlay', item}) - - presenter = buildPresenter({windowWidth, windowHeight, contentFrameWidth, boundingClientRect, gutterWidth}) - expectStateUpdate presenter, -> - presenter.setOverlayDimensions(decoration.id, itemWidth, itemHeight, contentMargin) - - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 1 * 10, left: 3 * 10 + gutterWidth} - } - - expectStateUpdate presenter, -> cursor.moveLeft() - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 1 * 10, left: -contentMargin} - } - - expectStateUpdate presenter, -> cursor.moveLeft() - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 1 * 10, left: -contentMargin} - } - - describe "when the editor is very small", -> - beforeEach -> - windowWidth = gutterWidth + 6 * 10 - windowHeight = 6 * 10 - contentFrameWidth = windowWidth - gutterWidth - boundingClientRect.width = windowWidth - boundingClientRect.height = windowHeight - - it "does not flip vertically and force the overlay to have a negative top", -> - marker = editor.markBufferPosition([1, 0], invalidate: 'never') - decoration = editor.decorateMarker(marker, {type: 'overlay', item}) - - presenter = buildPresenter({windowWidth, windowHeight, contentFrameWidth, boundingClientRect, gutterWidth}) - expectStateUpdate presenter, -> - presenter.setOverlayDimensions(decoration.id, itemWidth, itemHeight, contentMargin) - - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 2 * 10, left: 0 * 10 + gutterWidth} - } - - expectStateUpdate presenter, -> editor.insertNewline() - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 3 * 10, left: gutterWidth} - } - - it "does not adjust horizontally and force the overlay to have a negative left", -> - itemWidth = 6 * 10 - - marker = editor.markBufferPosition([0, 0], invalidate: 'never') - decoration = editor.decorateMarker(marker, {type: 'overlay', item}) - - presenter = buildPresenter({windowWidth, windowHeight, contentFrameWidth, boundingClientRect, gutterWidth}) - expectStateUpdate presenter, -> - presenter.setOverlayDimensions(decoration.id, itemWidth, itemHeight, contentMargin) - - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 10, left: gutterWidth} - } - - windowWidth = gutterWidth + 5 * 10 - expectStateUpdate presenter, -> presenter.setWindowSize(windowWidth, windowHeight) - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 10, left: windowWidth - itemWidth} - } - - windowWidth = gutterWidth + 1 * 10 - expectStateUpdate presenter, -> presenter.setWindowSize(windowWidth, windowHeight) - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 10, left: 0} - } - - windowWidth = gutterWidth - expectStateUpdate presenter, -> presenter.setWindowSize(windowWidth, windowHeight) - expectValues stateForOverlay(presenter, decoration), { - item: item - pixelPosition: {top: 10, left: 0} - } - - describe ".width", -> - it "is null when `editor.autoWidth` is false (the default)", -> - presenter = buildPresenter(explicitHeight: 50, gutterWidth: 20, contentFrameWidth: 300, baseCharacterWidth: 10) - expect(getState(presenter).width).toBeNull() - - it "equals to sum of .content.width and the width of the gutter when `editor.autoWidth` is true", -> - editor.setText('abcdef') - editor.update({autoWidth: true}) - presenter = buildPresenter(explicitHeight: 50, gutterWidth: 20, contentFrameWidth: 300, baseCharacterWidth: 10) - expect(getState(presenter).width).toBe(20 + 6 * 10 + 1) - - describe ".height", -> - it "updates model's rows per page when it changes", -> - presenter = buildPresenter(explicitHeight: 50, lineHeightInPixels: 10, horizontalScrollbarHeight: 10) - - getState(presenter) # trigger state update - expect(editor.getRowsPerPage()).toBe(4) - - presenter.setExplicitHeight(100) - getState(presenter) # trigger state update - expect(editor.getRowsPerPage()).toBe(9) - - presenter.setHorizontalScrollbarHeight(0) - getState(presenter) # trigger state update - expect(editor.getRowsPerPage()).toBe(10) - - presenter.setLineHeight(5) - getState(presenter) # trigger state update - expect(editor.getRowsPerPage()).toBe(20) - - it "tracks the computed content height if ::autoHeight is true so the editor auto-expands vertically", -> - presenter = buildPresenter(explicitHeight: null) - presenter.setAutoHeight(true) - expect(getState(presenter).height).toBe editor.getScreenLineCount() * 10 - - expectStateUpdate presenter, -> presenter.setAutoHeight(false) - expect(getState(presenter).height).toBe null - - expectStateUpdate presenter, -> presenter.setAutoHeight(true) - expect(getState(presenter).height).toBe editor.getScreenLineCount() * 10 - - expectStateUpdate presenter, -> presenter.setLineHeight(20) - expect(getState(presenter).height).toBe editor.getScreenLineCount() * 20 - - expectStateUpdate presenter, -> editor.getBuffer().append("\n\n\n") - expect(getState(presenter).height).toBe editor.getScreenLineCount() * 20 - - describe ".focused", -> - it "tracks the value of ::focused", -> - presenter = buildPresenter() - presenter.setFocused(false) - - expect(getState(presenter).focused).toBe false - expectStateUpdate presenter, -> presenter.setFocused(true) - expect(getState(presenter).focused).toBe true - expectStateUpdate presenter, -> presenter.setFocused(false) - expect(getState(presenter).focused).toBe false - - describe ".gutters", -> - getStateForGutterWithName = (presenter, gutterName) -> - gutterDescriptions = getState(presenter).gutters - for description in gutterDescriptions - gutter = description.gutter - return description if gutter.name is gutterName - - describe "the array itself, an array of gutter descriptions", -> - it "updates when gutters are added to the editor model, and keeps the gutters sorted by priority", -> - presenter = buildPresenter() - gutter1 = editor.addGutter({name: 'test-gutter-1', priority: -100, visible: true}) - gutter2 = editor.addGutter({name: 'test-gutter-2', priority: 100, visible: false}) - - expectedGutterOrder = [gutter1, editor.gutterWithName('line-number'), gutter2] - for gutterDescription, index in getState(presenter).gutters - expect(gutterDescription.gutter).toEqual expectedGutterOrder[index] - - it "updates when the visibility of a gutter changes", -> - presenter = buildPresenter() - gutter = editor.addGutter({name: 'test-gutter', visible: true}) - expect(getStateForGutterWithName(presenter, 'test-gutter').visible).toBe true - gutter.hide() - expect(getStateForGutterWithName(presenter, 'test-gutter').visible).toBe false - - it "updates when a gutter is removed", -> - presenter = buildPresenter() - gutter = editor.addGutter({name: 'test-gutter', visible: true}) - expect(getStateForGutterWithName(presenter, 'test-gutter').visible).toBe true - gutter.destroy() - expect(getStateForGutterWithName(presenter, 'test-gutter')).toBeUndefined() - - describe "for a gutter description that corresponds to the line-number gutter", -> - getLineNumberGutterState = (presenter) -> - gutterDescriptions = getState(presenter).gutters - for description in gutterDescriptions - gutter = description.gutter - return description if gutter.name is 'line-number' - - describe ".visible", -> - it "is true iff the editor isn't mini and has ::isLineNumberGutterVisible and ::doesShowLineNumbers set to true", -> - presenter = buildPresenter() - - expect(editor.isLineNumberGutterVisible()).toBe true - expect(getLineNumberGutterState(presenter).visible).toBe true - - expectStateUpdate presenter, -> editor.update({mini: true}) - expect(getLineNumberGutterState(presenter)).toBeUndefined() - - expectStateUpdate presenter, -> editor.update({mini: false}) - expect(getLineNumberGutterState(presenter).visible).toBe true - - expectStateUpdate presenter, -> editor.update({lineNumberGutterVisible: false}) - expect(getLineNumberGutterState(presenter).visible).toBe false - - expectStateUpdate presenter, -> editor.update({lineNumberGutterVisible: true}) - expect(getLineNumberGutterState(presenter).visible).toBe true - - expectStateUpdate presenter, -> editor.update({showLineNumbers: false}) - expect(getLineNumberGutterState(presenter).visible).toBe false - - describe ".content.maxLineNumberDigits", -> - it "is set to the number of digits used by the greatest line number", -> - presenter = buildPresenter() - expect(editor.getLastBufferRow()).toBe 12 - expect(getLineNumberGutterState(presenter).content.maxLineNumberDigits).toBe 2 - - editor.setText("1\n2\n3") - expect(getLineNumberGutterState(presenter).content.maxLineNumberDigits).toBe 2 - - describe ".content.tiles", -> - lineNumberStateForScreenRow = (presenter, screenRow) -> - tilesState = getLineNumberGutterState(presenter).content.tiles - line = presenter.linesByScreenRow.get(screenRow) - tilesState[presenter.tileForRow(screenRow)]?.lineNumbers[line?.id] - - tiledContentContract (presenter) -> getLineNumberGutterState(presenter).content - - describe ".lineNumbers[id]", -> - it "contains states for line numbers that are visible on screen", -> - editor.foldBufferRow(4) - editor.update({softWrapped: true}) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(51) - presenter = buildPresenter(explicitHeight: 25, scrollTop: 30, lineHeight: 10, tileSize: 3) - presenter.setScreenRowsToMeasure([9, 11]) - - expect(lineNumberStateForScreenRow(presenter, 2)).toBeUndefined() - expectValues lineNumberStateForScreenRow(presenter, 3), {screenRow: 3, bufferRow: 3, softWrapped: false} - expectValues lineNumberStateForScreenRow(presenter, 4), {screenRow: 4, bufferRow: 3, softWrapped: true} - expectValues lineNumberStateForScreenRow(presenter, 5), {screenRow: 5, bufferRow: 4, softWrapped: false} - expectValues lineNumberStateForScreenRow(presenter, 6), {screenRow: 6, bufferRow: 7, softWrapped: false} - expectValues lineNumberStateForScreenRow(presenter, 7), {screenRow: 7, bufferRow: 8, softWrapped: false} - expectValues lineNumberStateForScreenRow(presenter, 8), {screenRow: 8, bufferRow: 8, softWrapped: true} - expect(lineNumberStateForScreenRow(presenter, 9)).toBeUndefined() - expect(lineNumberStateForScreenRow(presenter, 10)).toBeUndefined() - expect(lineNumberStateForScreenRow(presenter, 11)).toBeUndefined() - expect(lineNumberStateForScreenRow(presenter, 12)).toBeUndefined() - - it "updates when the editor's content changes", -> - editor.foldBufferRow(4) - editor.update({softWrapped: true}) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(50) - presenter = buildPresenter(explicitHeight: 35, scrollTop: 30, tileSize: 2) - - expect(lineNumberStateForScreenRow(presenter, 1)).toBeUndefined() - expectValues lineNumberStateForScreenRow(presenter, 2), {bufferRow: 2} - expectValues lineNumberStateForScreenRow(presenter, 3), {bufferRow: 3} - expectValues lineNumberStateForScreenRow(presenter, 4), {bufferRow: 3} - expectValues lineNumberStateForScreenRow(presenter, 5), {bufferRow: 4} - expectValues lineNumberStateForScreenRow(presenter, 6), {bufferRow: 7} - expectValues lineNumberStateForScreenRow(presenter, 7), {bufferRow: 8} - expectValues lineNumberStateForScreenRow(presenter, 8), {bufferRow: 8} - expectValues lineNumberStateForScreenRow(presenter, 9), {bufferRow: 9} - expect(lineNumberStateForScreenRow(presenter, 10)).toBeUndefined() - - expectStateUpdate presenter, -> - editor.getBuffer().insert([3, Infinity], new Array(25).join("x ")) - - expect(lineNumberStateForScreenRow(presenter, 1)).toBeUndefined() - expectValues lineNumberStateForScreenRow(presenter, 2), {bufferRow: 2} - expectValues lineNumberStateForScreenRow(presenter, 3), {bufferRow: 3} - expectValues lineNumberStateForScreenRow(presenter, 4), {bufferRow: 3} - expectValues lineNumberStateForScreenRow(presenter, 5), {bufferRow: 3} - expectValues lineNumberStateForScreenRow(presenter, 6), {bufferRow: 4} - expectValues lineNumberStateForScreenRow(presenter, 7), {bufferRow: 7} - expectValues lineNumberStateForScreenRow(presenter, 8), {bufferRow: 8} - expectValues lineNumberStateForScreenRow(presenter, 9), {bufferRow: 8} - expect(lineNumberStateForScreenRow(presenter, 10)).toBeUndefined() - - it "correctly handles the first screen line being soft-wrapped", -> - editor.update({softWrapped: true}) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(30) - presenter = buildPresenter(explicitHeight: 25, scrollTop: 50, tileSize: 2) - - expectValues lineNumberStateForScreenRow(presenter, 5), {screenRow: 5, bufferRow: 3, softWrapped: true} - expectValues lineNumberStateForScreenRow(presenter, 6), {screenRow: 6, bufferRow: 3, softWrapped: true} - expectValues lineNumberStateForScreenRow(presenter, 7), {screenRow: 7, bufferRow: 4, softWrapped: false} - - presenter.setContentFrameWidth(500) - - expectValues lineNumberStateForScreenRow(presenter, 5), {screenRow: 5, bufferRow: 4, softWrapped: false} - expectValues lineNumberStateForScreenRow(presenter, 6), {screenRow: 6, bufferRow: 5, softWrapped: false} - expectValues lineNumberStateForScreenRow(presenter, 7), {screenRow: 7, bufferRow: 6, softWrapped: false} - - describe ".blockDecorationsHeight", -> - it "adds the sum of all block decorations' heights to the relevant line number state objects, both initially and when decorations change", -> - blockDecoration1 = addBlockDecorationBeforeScreenRow(0) - presenter = buildPresenter(tileSize: 2, explicitHeight: 300) - blockDecoration2 = addBlockDecorationBeforeScreenRow(3) - blockDecoration3 = addBlockDecorationBeforeScreenRow(3) - blockDecoration4 = addBlockDecorationBeforeScreenRow(7) - blockDecoration5 = addBlockDecorationAfterScreenRow(7) - blockDecoration6 = addBlockDecorationAfterScreenRow(10) - - presenter.setBlockDecorationDimensions(blockDecoration1, 0, 10) - presenter.setBlockDecorationDimensions(blockDecoration2, 0, 20) - presenter.setBlockDecorationDimensions(blockDecoration3, 0, 30) - presenter.setBlockDecorationDimensions(blockDecoration4, 0, 35) - presenter.setBlockDecorationDimensions(blockDecoration4, 0, 40) - presenter.setBlockDecorationDimensions(blockDecoration5, 0, 50) - - waitsForStateToUpdate presenter, -> presenter.setBlockDecorationDimensions(blockDecoration6, 0, 60) - runs -> - expect(lineNumberStateForScreenRow(presenter, 0).blockDecorationsHeight).toBe(10) - expect(lineNumberStateForScreenRow(presenter, 1).blockDecorationsHeight).toBe(0) - expect(lineNumberStateForScreenRow(presenter, 2).blockDecorationsHeight).toBe(0) - expect(lineNumberStateForScreenRow(presenter, 3).blockDecorationsHeight).toBe(20 + 30) - expect(lineNumberStateForScreenRow(presenter, 4).blockDecorationsHeight).toBe(0) - expect(lineNumberStateForScreenRow(presenter, 5).blockDecorationsHeight).toBe(0) - expect(lineNumberStateForScreenRow(presenter, 6).blockDecorationsHeight).toBe(0) - expect(lineNumberStateForScreenRow(presenter, 7).blockDecorationsHeight).toBe(40) - expect(lineNumberStateForScreenRow(presenter, 8).blockDecorationsHeight).toBe(0) # 0 because we're at the start of a tile. - expect(lineNumberStateForScreenRow(presenter, 9).blockDecorationsHeight).toBe(0) - expect(lineNumberStateForScreenRow(presenter, 10).blockDecorationsHeight).toBe(0) - expect(lineNumberStateForScreenRow(presenter, 11).blockDecorationsHeight).toBe(60) - - waitsForStateToUpdate presenter, -> - blockDecoration1.getMarker().setHeadBufferPosition([1, 0]) - blockDecoration2.getMarker().setHeadBufferPosition([5, 0]) - blockDecoration3.getMarker().setHeadBufferPosition([9, 0]) - - runs -> - expect(lineNumberStateForScreenRow(presenter, 0).blockDecorationsHeight).toBe(0) - expect(lineNumberStateForScreenRow(presenter, 1).blockDecorationsHeight).toBe(10) - expect(lineNumberStateForScreenRow(presenter, 2).blockDecorationsHeight).toBe(0) - expect(lineNumberStateForScreenRow(presenter, 3).blockDecorationsHeight).toBe(0) - expect(lineNumberStateForScreenRow(presenter, 4).blockDecorationsHeight).toBe(0) - expect(lineNumberStateForScreenRow(presenter, 5).blockDecorationsHeight).toBe(20) - expect(lineNumberStateForScreenRow(presenter, 6).blockDecorationsHeight).toBe(0) - expect(lineNumberStateForScreenRow(presenter, 7).blockDecorationsHeight).toBe(40) - expect(lineNumberStateForScreenRow(presenter, 8).blockDecorationsHeight).toBe(0) # 0 because we're at the start of a tile. - expect(lineNumberStateForScreenRow(presenter, 9).blockDecorationsHeight).toBe(30) - expect(lineNumberStateForScreenRow(presenter, 10).blockDecorationsHeight).toBe(0) - expect(lineNumberStateForScreenRow(presenter, 11).blockDecorationsHeight).toBe(60) - - waitsForStateToUpdate presenter, -> - blockDecoration1.destroy() - blockDecoration3.destroy() - - runs -> - expect(lineNumberStateForScreenRow(presenter, 0).blockDecorationsHeight).toBe(0) - expect(lineNumberStateForScreenRow(presenter, 1).blockDecorationsHeight).toBe(0) - expect(lineNumberStateForScreenRow(presenter, 2).blockDecorationsHeight).toBe(0) - expect(lineNumberStateForScreenRow(presenter, 3).blockDecorationsHeight).toBe(0) - expect(lineNumberStateForScreenRow(presenter, 4).blockDecorationsHeight).toBe(0) - expect(lineNumberStateForScreenRow(presenter, 5).blockDecorationsHeight).toBe(20) - expect(lineNumberStateForScreenRow(presenter, 6).blockDecorationsHeight).toBe(0) - expect(lineNumberStateForScreenRow(presenter, 7).blockDecorationsHeight).toBe(40) - expect(lineNumberStateForScreenRow(presenter, 8).blockDecorationsHeight).toBe(0) # 0 because we're at the start of a tile. - expect(lineNumberStateForScreenRow(presenter, 9).blockDecorationsHeight).toBe(0) - expect(lineNumberStateForScreenRow(presenter, 10).blockDecorationsHeight).toBe(0) - expect(lineNumberStateForScreenRow(presenter, 11).blockDecorationsHeight).toBe(60) - - describe ".decorationClasses", -> - it "adds decoration classes to the relevant line number state objects, both initially and when decorations change", -> - marker1 = editor.addMarkerLayer(maintainHistory: true).markBufferRange([[4, 0], [6, 2]], invalidate: 'touch') - decoration1 = editor.decorateMarker(marker1, type: 'line-number', class: 'a') - marker2 = editor.addMarkerLayer(maintainHistory: true).markBufferRange([[4, 0], [6, 2]], invalidate: 'touch') - decoration2 = editor.decorateMarker(marker2, type: 'line-number', class: 'b') - presenter = buildPresenter() - - expect(lineNumberStateForScreenRow(presenter, 3).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toEqual ['a', 'b'] - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toEqual ['a', 'b'] - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toEqual ['a', 'b'] - expect(lineNumberStateForScreenRow(presenter, 7).decorationClasses).toBeNull() - - waitsForStateToUpdate presenter, -> editor.getBuffer().insert([5, 0], 'x') - runs -> - expect(marker1.isValid()).toBe false - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toBeNull() - - waitsForStateToUpdate presenter, -> editor.undo() - runs -> - expect(lineNumberStateForScreenRow(presenter, 3).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toEqual ['a', 'b'] - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toEqual ['a', 'b'] - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toEqual ['a', 'b'] - expect(lineNumberStateForScreenRow(presenter, 7).decorationClasses).toBeNull() - - waitsForStateToUpdate presenter, -> marker1.setBufferRange([[2, 0], [4, 2]]) - runs -> - expect(lineNumberStateForScreenRow(presenter, 1).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 2).decorationClasses).toEqual ['a'] - expect(lineNumberStateForScreenRow(presenter, 3).decorationClasses).toEqual ['a'] - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toEqual ['a', 'b'] - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toEqual ['b'] - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toEqual ['b'] - expect(lineNumberStateForScreenRow(presenter, 7).decorationClasses).toBeNull() - - waitsForStateToUpdate presenter, -> decoration1.destroy() - runs -> - expect(lineNumberStateForScreenRow(presenter, 2).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 3).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toEqual ['b'] - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toEqual ['b'] - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toEqual ['b'] - expect(lineNumberStateForScreenRow(presenter, 7).decorationClasses).toBeNull() - - waitsForStateToUpdate presenter, -> marker2.destroy() - runs -> - expect(lineNumberStateForScreenRow(presenter, 2).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 3).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 7).decorationClasses).toBeNull() - - it "honors the 'onlyEmpty' option on line-number decorations", -> - marker = editor.markBufferRange([[4, 0], [6, 1]]) - decoration = editor.decorateMarker(marker, type: 'line-number', class: 'a', onlyEmpty: true) - presenter = buildPresenter() - - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toBeNull() - - waitsForStateToUpdate presenter, -> marker.clearTail() - - runs -> - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toEqual ['a'] - - it "honors the 'onlyNonEmpty' option on line-number decorations", -> - marker = editor.markBufferRange([[4, 0], [6, 2]]) - decoration = editor.decorateMarker(marker, type: 'line-number', class: 'a', onlyNonEmpty: true) - presenter = buildPresenter() - - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toEqual ['a'] - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toEqual ['a'] - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toEqual ['a'] - - waitsForStateToUpdate presenter, -> marker.clearTail() - - runs -> - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toBeNull() - - it "honors the 'onlyHead' option on line-number decorations", -> - marker = editor.markBufferRange([[4, 0], [6, 2]]) - decoration = editor.decorateMarker(marker, type: 'line-number', class: 'a', onlyHead: true) - presenter = buildPresenter() - - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toEqual ['a'] - - it "does not decorate the last line of a non-empty line-number decoration range if it ends at column 0", -> - marker = editor.markBufferRange([[4, 0], [6, 0]]) - decoration = editor.decorateMarker(marker, type: 'line-number', class: 'a') - presenter = buildPresenter() - - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toEqual ['a'] - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toEqual ['a'] - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toBeNull() - - it "does not apply line-number decorations to mini editors", -> - editor.setMini(true) - presenter = buildPresenter() - marker = editor.markBufferRange([[0, 0], [0, 0]]) - decoration = editor.decorateMarker(marker, type: 'line-number', class: 'a') - # A mini editor will have no gutters. - expect(getLineNumberGutterState(presenter)).toBeUndefined() - - expectStateUpdate presenter, -> editor.setMini(false) - expect(lineNumberStateForScreenRow(presenter, 0).decorationClasses).toEqual ['cursor-line', 'cursor-line-no-selection', 'a'] - - expectStateUpdate presenter, -> editor.setMini(true) - expect(getLineNumberGutterState(presenter)).toBeUndefined() - - it "only applies line-number decorations to screen rows that are spanned by their marker when lines are soft-wrapped", -> - editor.setText("a line that wraps, ok") - editor.update({softWrapped: true}) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(16) - marker = editor.markBufferRange([[0, 0], [0, 2]]) - editor.decorateMarker(marker, type: 'line-number', class: 'a') - presenter = buildPresenter(explicitHeight: 10) - - expect(lineNumberStateForScreenRow(presenter, 0).decorationClasses).toContain 'a' - expect(lineNumberStateForScreenRow(presenter, 1).decorationClasses).toBeNull() - - waitsForStateToUpdate presenter, -> marker.setBufferRange([[0, 0], [0, Infinity]]) - runs -> - expect(lineNumberStateForScreenRow(presenter, 0).decorationClasses).toContain 'a' - expect(lineNumberStateForScreenRow(presenter, 1).decorationClasses).toContain 'a' - - describe "when a fold spans a single soft-wrapped buffer row", -> - it "applies the 'folded' decoration only to its initial screen row", -> - editor.update({softWrapped: true}) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(20) - editor.foldBufferRange([[0, 20], [0, 22]]) - editor.foldBufferRange([[0, 10], [0, 14]]) - presenter = buildPresenter(explicitHeight: 35, scrollTop: 0, tileSize: 2) - - expect(lineNumberStateForScreenRow(presenter, 0).decorationClasses).toContain('folded') - expect(lineNumberStateForScreenRow(presenter, 1).decorationClasses).toBeNull() - - describe "when a fold is at the end of a soft-wrapped buffer row", -> - it "applies the 'folded' decoration only to its initial screen row", -> - editor.update({softWrapped: true}) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(25) - editor.foldBufferRow(1) - presenter = buildPresenter(explicitHeight: 35, scrollTop: 0, tileSize: 2) - - expect(lineNumberStateForScreenRow(presenter, 2).decorationClasses).toContain('folded') - expect(lineNumberStateForScreenRow(presenter, 3).decorationClasses).toBeNull() - - describe ".foldable", -> - it "marks line numbers at the start of a foldable region as foldable", -> - presenter = buildPresenter() - expect(lineNumberStateForScreenRow(presenter, 0).foldable).toBe true - expect(lineNumberStateForScreenRow(presenter, 1).foldable).toBe true - expect(lineNumberStateForScreenRow(presenter, 2).foldable).toBe false - expect(lineNumberStateForScreenRow(presenter, 3).foldable).toBe false - expect(lineNumberStateForScreenRow(presenter, 4).foldable).toBe true - expect(lineNumberStateForScreenRow(presenter, 5).foldable).toBe false - - it "updates the foldable class on the correct line numbers when the foldable positions change", -> - presenter = buildPresenter() - editor.getBuffer().insert([0, 0], '\n') - expect(lineNumberStateForScreenRow(presenter, 0).foldable).toBe false - expect(lineNumberStateForScreenRow(presenter, 1).foldable).toBe true - expect(lineNumberStateForScreenRow(presenter, 2).foldable).toBe true - expect(lineNumberStateForScreenRow(presenter, 3).foldable).toBe false - expect(lineNumberStateForScreenRow(presenter, 4).foldable).toBe false - expect(lineNumberStateForScreenRow(presenter, 5).foldable).toBe true - expect(lineNumberStateForScreenRow(presenter, 6).foldable).toBe false - - it "updates the foldable class on a line number that becomes foldable", -> - presenter = buildPresenter() - expect(lineNumberStateForScreenRow(presenter, 11).foldable).toBe false - - editor.getBuffer().insert([11, 44], '\n fold me') - expect(lineNumberStateForScreenRow(presenter, 11).foldable).toBe true - - editor.undo() - expect(lineNumberStateForScreenRow(presenter, 11).foldable).toBe false - - describe "for a gutter description that corresponds to a custom gutter", -> - describe ".content", -> - getContentForGutterWithName = (presenter, gutterName) -> - fullState = getStateForGutterWithName(presenter, gutterName) - return fullState.content if fullState - - [presenter, gutter, decorationItem, decorationParams] = [] - [marker1, decoration1, marker2, decoration2, marker3, decoration3] = [] - - # Set the scrollTop to 0 to show the very top of the file. - # Set the explicitHeight to make 10 lines visible. - scrollTop = 0 - lineHeight = 10 - explicitHeight = lineHeight * 10 - - beforeEach -> - # At the beginning of each test, decoration1 and decoration2 are in visible range, - # but not decoration3. - presenter = buildPresenter({explicitHeight, scrollTop, lineHeight}) - gutter = editor.addGutter({name: 'test-gutter', visible: true}) - decorationItem = document.createElement('div') - decorationItem.class = 'decoration-item' - decorationParams = - type: 'gutter' - gutterName: 'test-gutter' - class: 'test-class' - item: decorationItem - marker1 = editor.markBufferRange([[0, 0], [1, 0]]) - decoration1 = editor.decorateMarker(marker1, decorationParams) - marker2 = editor.markBufferRange([[9, 0], [12, 0]]) - decoration2 = editor.decorateMarker(marker2, decorationParams) - marker3 = editor.markBufferRange([[13, 0], [14, 0]]) - decoration3 = editor.decorateMarker(marker3, decorationParams) - - # Clear any batched state updates. - getState(presenter) - - it "contains all decorations within the visible buffer range", -> - decorationState = getContentForGutterWithName(presenter, 'test-gutter') - expect(decorationState[decoration1.id].top).toBe lineHeight * marker1.getScreenRange().start.row - expect(decorationState[decoration1.id].height).toBe lineHeight * marker1.getScreenRange().getRowCount() - expect(decorationState[decoration1.id].item).toBe decorationItem - expect(decorationState[decoration1.id].class).toBe 'test-class' - - expect(decorationState[decoration2.id].top).toBe lineHeight * marker2.getScreenRange().start.row - expect(decorationState[decoration2.id].height).toBe lineHeight * marker2.getScreenRange().getRowCount() - expect(decorationState[decoration2.id].item).toBe decorationItem - expect(decorationState[decoration2.id].class).toBe 'test-class' - - expect(decorationState[decoration3.id]).toBeUndefined() - - it "updates all the gutters, even when a gutter with higher priority is hidden", -> - hiddenGutter = {name: 'test-gutter-1', priority: -150, visible: false} - editor.addGutter(hiddenGutter) - - # This update will scroll decoration1 out of view, and decoration3 into view. - expectStateUpdate presenter, -> presenter.setScrollTop(scrollTop + lineHeight * 5) - - decorationState = getContentForGutterWithName(presenter, 'test-gutter') - expect(decorationState[decoration1.id]).toBeUndefined() - expect(decorationState[decoration3.id].top).toBeDefined() - - it "updates when block decorations are added, changed or removed", -> - # block decoration before decoration1 - blockDecoration1 = addBlockDecorationBeforeScreenRow(0) - presenter.setBlockDecorationDimensions(blockDecoration1, 0, 3) - # block decoration between decoration1 and decoration2 - blockDecoration2 = addBlockDecorationBeforeScreenRow(3) - presenter.setBlockDecorationDimensions(blockDecoration2, 0, 5) - # block decoration between decoration2 and decoration3 - blockDecoration3 = addBlockDecorationBeforeScreenRow(10) - blockDecoration4 = addBlockDecorationAfterScreenRow(10) - presenter.setBlockDecorationDimensions(blockDecoration3, 0, 7) - presenter.setBlockDecorationDimensions(blockDecoration4, 0, 11) - - decorationState = getContentForGutterWithName(presenter, 'test-gutter') - expect(decorationState[decoration1.id].top).toBe lineHeight * marker1.getScreenRange().start.row + 3 - expect(decorationState[decoration1.id].height).toBe lineHeight * marker1.getScreenRange().getRowCount() - expect(decorationState[decoration1.id].item).toBe decorationItem - expect(decorationState[decoration1.id].class).toBe 'test-class' - expect(decorationState[decoration2.id].top).toBe lineHeight * marker2.getScreenRange().start.row + 3 + 5 - expect(decorationState[decoration2.id].height).toBe lineHeight * marker2.getScreenRange().getRowCount() + 7 + 11 - expect(decorationState[decoration2.id].item).toBe decorationItem - expect(decorationState[decoration2.id].class).toBe 'test-class' - expect(decorationState[decoration3.id]).toBeUndefined() - - presenter.setScrollTop(scrollTop + lineHeight * 5) - - decorationState = getContentForGutterWithName(presenter, 'test-gutter') - expect(decorationState[decoration1.id]).toBeUndefined() - expect(decorationState[decoration2.id].top).toBe lineHeight * marker2.getScreenRange().start.row + 3 + 5 - expect(decorationState[decoration2.id].height).toBe lineHeight * marker2.getScreenRange().getRowCount() + 7 + 11 - expect(decorationState[decoration2.id].item).toBe decorationItem - expect(decorationState[decoration2.id].class).toBe 'test-class' - expect(decorationState[decoration3.id].top).toBe lineHeight * marker3.getScreenRange().start.row + 3 + 5 + 7 + 11 - expect(decorationState[decoration3.id].height).toBe lineHeight * marker3.getScreenRange().getRowCount() - expect(decorationState[decoration3.id].item).toBe decorationItem - expect(decorationState[decoration3.id].class).toBe 'test-class' - - waitsForStateToUpdate presenter, -> blockDecoration1.destroy() - runs -> - decorationState = getContentForGutterWithName(presenter, 'test-gutter') - expect(decorationState[decoration1.id]).toBeUndefined() - expect(decorationState[decoration2.id].top).toBe lineHeight * marker2.getScreenRange().start.row + 5 - expect(decorationState[decoration2.id].height).toBe lineHeight * marker2.getScreenRange().getRowCount() + 7 + 11 - expect(decorationState[decoration2.id].item).toBe decorationItem - expect(decorationState[decoration2.id].class).toBe 'test-class' - expect(decorationState[decoration3.id].top).toBe lineHeight * marker3.getScreenRange().start.row + 5 + 7 + 11 - expect(decorationState[decoration3.id].height).toBe lineHeight * marker3.getScreenRange().getRowCount() - expect(decorationState[decoration3.id].item).toBe decorationItem - expect(decorationState[decoration3.id].class).toBe 'test-class' - - it "updates when ::scrollTop changes", -> - # This update will scroll decoration1 out of view, and decoration3 into view. - expectStateUpdate presenter, -> presenter.setScrollTop(scrollTop + lineHeight * 5) - - decorationState = getContentForGutterWithName(presenter, 'test-gutter') - expect(decorationState[decoration1.id]).toBeUndefined() - expect(decorationState[decoration2.id].top).toBeDefined() - expect(decorationState[decoration3.id].top).toBeDefined() - - it "updates when ::explicitHeight changes", -> - # This update will make all three decorations visible. - expectStateUpdate presenter, -> presenter.setExplicitHeight(explicitHeight + lineHeight * 5) - - decorationState = getContentForGutterWithName(presenter, 'test-gutter') - expect(decorationState[decoration1.id].top).toBeDefined() - expect(decorationState[decoration2.id].top).toBeDefined() - expect(decorationState[decoration3.id].top).toBeDefined() - - it "updates when ::lineHeight changes", -> - # This update will make all three decorations visible. - expectStateUpdate presenter, -> presenter.setLineHeight(Math.ceil(1.0 * explicitHeight / marker3.getBufferRange().end.row)) - - decorationState = getContentForGutterWithName(presenter, 'test-gutter') - - expect(decorationState[decoration1.id].top).toBeDefined() - expect(decorationState[decoration2.id].top).toBeDefined() - expect(decorationState[decoration3.id].top).toBeDefined() - - it "updates when the editor's content changes", -> - # This update will add enough lines to push decoration2 out of view. - expectStateUpdate presenter, -> editor.setTextInBufferRange([[8, 0], [9, 0]], '\n\n\n\n\n') - - decorationState = getContentForGutterWithName(presenter, 'test-gutter') - expect(decorationState[decoration1.id].top).toBeDefined() - expect(decorationState[decoration2.id]).toBeUndefined() - expect(decorationState[decoration3.id]).toBeUndefined() - - it "updates when a decoration's marker is modified", -> - # This update will move decoration1 out of view. - waitsForStateToUpdate presenter, -> - newRange = new Range([13, 0], [14, 0]) - marker1.setBufferRange(newRange) - - runs -> - decorationState = getContentForGutterWithName(presenter, 'test-gutter') - expect(decorationState[decoration1.id]).toBeUndefined() - expect(decorationState[decoration2.id].top).toBeDefined() - expect(decorationState[decoration3.id]).toBeUndefined() - - describe "when a decoration's properties are modified", -> - it "updates the item applied to the decoration, if the decoration item is changed", -> - # This changes the decoration class. The visibility of the decoration should not be affected. - newItem = document.createElement('div') - newItem.class = 'new-decoration-item' - newDecorationParams = - type: 'gutter' - gutterName: 'test-gutter' - class: 'test-class' - item: newItem - - waitsForStateToUpdate presenter, -> decoration1.setProperties(newDecorationParams) - - runs -> - decorationState = getContentForGutterWithName(presenter, 'test-gutter') - expect(decorationState[decoration1.id].item).toBe newItem - expect(decorationState[decoration2.id].item).toBe decorationItem - expect(decorationState[decoration3.id]).toBeUndefined() - - it "updates the class applied to the decoration, if the decoration class is changed", -> - # This changes the decoration item. The visibility of the decoration should not be affected. - newDecorationParams = - type: 'gutter' - gutterName: 'test-gutter' - class: 'new-test-class' - item: decorationItem - waitsForStateToUpdate presenter, -> decoration1.setProperties(newDecorationParams) - - runs -> - decorationState = getContentForGutterWithName(presenter, 'test-gutter') - expect(decorationState[decoration1.id].class).toBe 'new-test-class' - expect(decorationState[decoration2.id].class).toBe 'test-class' - expect(decorationState[decoration3.id]).toBeUndefined() - - it "updates the type of the decoration, if the decoration type is changed", -> - # This changes the type of the decoration. This should remove the decoration from the gutter. - newDecorationParams = - type: 'line' - gutterName: 'test-gutter' # This is an invalid/meaningless option here, but it shouldn't matter. - class: 'test-class' - item: decorationItem - waitsForStateToUpdate presenter, -> decoration1.setProperties(newDecorationParams) - - runs -> - decorationState = getContentForGutterWithName(presenter, 'test-gutter') - expect(decorationState[decoration1.id]).toBeUndefined() - expect(decorationState[decoration2.id].top).toBeDefined() - expect(decorationState[decoration3.id]).toBeUndefined() - - it "updates the gutter the decoration targets, if the decoration gutterName is changed", -> - # This changes which gutter this decoration applies to. Since this gutter does not exist, - # the decoration should not appear in the customDecorations state. - newDecorationParams = - type: 'gutter' - gutterName: 'test-gutter-2' - class: 'new-test-class' - item: decorationItem - waitsForStateToUpdate presenter, -> decoration1.setProperties(newDecorationParams) - - runs -> - decorationState = getContentForGutterWithName(presenter, 'test-gutter') - expect(decorationState[decoration1.id]).toBeUndefined() - expect(decorationState[decoration2.id].top).toBeDefined() - expect(decorationState[decoration3.id]).toBeUndefined() - - # After adding the targeted gutter, the decoration will appear in the state for that gutter, - # since it should be visible. - expectStateUpdate presenter, -> editor.addGutter({name: 'test-gutter-2'}) - newGutterDecorationState = getContentForGutterWithName(presenter, 'test-gutter-2') - expect(newGutterDecorationState[decoration1.id].top).toBeDefined() - expect(newGutterDecorationState[decoration2.id]).toBeUndefined() - expect(newGutterDecorationState[decoration3.id]).toBeUndefined() - oldGutterDecorationState = getContentForGutterWithName(presenter, 'test-gutter') - expect(oldGutterDecorationState[decoration1.id]).toBeUndefined() - expect(oldGutterDecorationState[decoration2.id].top).toBeDefined() - expect(oldGutterDecorationState[decoration3.id]).toBeUndefined() - - it "updates when the editor's mini state changes, and is cleared when the editor is mini", -> - expectStateUpdate presenter, -> editor.setMini(true) - decorationState = getContentForGutterWithName(presenter, 'test-gutter') - expect(decorationState).toBeUndefined() - - # The decorations should return to the original state. - expectStateUpdate presenter, -> editor.setMini(false) - decorationState = getContentForGutterWithName(presenter, 'test-gutter') - expect(decorationState[decoration1.id].top).toBeDefined() - expect(decorationState[decoration2.id].top).toBeDefined() - expect(decorationState[decoration3.id]).toBeUndefined() - - it "updates when a gutter's visibility changes, and is cleared when the gutter is not visible", -> - expectStateUpdate presenter, -> gutter.hide() - decorationState = getContentForGutterWithName(presenter, 'test-gutter') - expect(decorationState[decoration1.id]).toBeUndefined() - expect(decorationState[decoration2.id]).toBeUndefined() - expect(decorationState[decoration3.id]).toBeUndefined() - - # The decorations should return to the original state. - expectStateUpdate presenter, -> gutter.show() - decorationState = getContentForGutterWithName(presenter, 'test-gutter') - expect(decorationState[decoration1.id].top).toBeDefined() - expect(decorationState[decoration2.id].top).toBeDefined() - expect(decorationState[decoration3.id]).toBeUndefined() - - it "updates when a gutter is added to the editor", -> - decorationParams = - type: 'gutter' - gutterName: 'test-gutter-2' - class: 'test-class' - marker4 = editor.markBufferRange([[0, 0], [1, 0]]) - decoration4 = null - - waitsForStateToUpdate presenter, -> decoration4 = editor.decorateMarker(marker4, decorationParams) - - runs -> - expectStateUpdate presenter, -> editor.addGutter({name: 'test-gutter-2'}) - - decorationState = getContentForGutterWithName(presenter, 'test-gutter-2') - expect(decorationState[decoration1.id]).toBeUndefined() - expect(decorationState[decoration2.id]).toBeUndefined() - expect(decorationState[decoration3.id]).toBeUndefined() - expect(decorationState[decoration4.id].top).toBeDefined() - - it "updates when editor lines are folded", -> - oldDimensionsForDecoration1 = - top: lineHeight * marker1.getScreenRange().start.row - height: lineHeight * marker1.getScreenRange().getRowCount() - oldDimensionsForDecoration2 = - top: lineHeight * marker2.getScreenRange().start.row - height: lineHeight * marker2.getScreenRange().getRowCount() - - # Based on the contents of sample.js, this should affect all but the top - # part of decoration1. - expectStateUpdate presenter, -> editor.foldBufferRow(0) - - decorationState = getContentForGutterWithName(presenter, 'test-gutter') - expect(decorationState[decoration1.id].top).toBe oldDimensionsForDecoration1.top - expect(decorationState[decoration1.id].height).not.toBe oldDimensionsForDecoration1.height - # Due to the issue described here: https://github.com/atom/atom/issues/6454, decoration2 - # will be bumped up to the row that was folded and still made visible, instead of being - # entirely collapsed. (The same thing will happen to decoration3.) - expect(decorationState[decoration2.id].top).not.toBe oldDimensionsForDecoration2.top - expect(decorationState[decoration2.id].height).not.toBe oldDimensionsForDecoration2.height - - describe "regardless of what kind of gutter a gutter description corresponds to", -> - [customGutter] = [] - - getStylesForGutterWithName = (presenter, gutterName) -> - fullState = getStateForGutterWithName(presenter, gutterName) - return fullState.styles if fullState - - beforeEach -> - customGutter = editor.addGutter({name: 'test-gutter', priority: -1, visible: true}) - - afterEach -> - customGutter.destroy() - - describe ".scrollHeight", -> - it "updates when new block decorations are measured, changed or destroyed", -> - presenter = buildPresenter(scrollTop: 0, lineHeight: 10) - expect(getState(presenter).verticalScrollbar.scrollHeight).toBe editor.getScreenLineCount() * 10 - - blockDecoration1 = addBlockDecorationBeforeScreenRow(0) - blockDecoration2 = addBlockDecorationBeforeScreenRow(3) - blockDecoration3 = addBlockDecorationBeforeScreenRow(7) - - presenter.setBlockDecorationDimensions(blockDecoration1, 0, 35.8) - presenter.setBlockDecorationDimensions(blockDecoration2, 0, 50.3) - presenter.setBlockDecorationDimensions(blockDecoration3, 0, 95.2) - - linesHeight = editor.getScreenLineCount() * 10 - blockDecorationsHeight = Math.round(35.8 + 50.3 + 95.2) - expect(getStylesForGutterWithName(presenter, 'line-number').scrollHeight).toBe(linesHeight + blockDecorationsHeight) - - presenter.setBlockDecorationDimensions(blockDecoration2, 0, 100.3) - - blockDecorationsHeight = Math.round(35.8 + 100.3 + 95.2) - expect(getStylesForGutterWithName(presenter, 'line-number').scrollHeight).toBe(linesHeight + blockDecorationsHeight) - - waitsForStateToUpdate presenter, -> blockDecoration3.destroy() - runs -> - blockDecorationsHeight = Math.round(35.8 + 100.3) - expect(getStylesForGutterWithName(presenter, 'line-number').scrollHeight).toBe(linesHeight + blockDecorationsHeight) - - it "is initialized based on ::lineHeight, the number of lines, and ::explicitHeight", -> - presenter = buildPresenter() - expect(getStylesForGutterWithName(presenter, 'line-number').scrollHeight).toBe editor.getScreenLineCount() * 10 - expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollHeight).toBe editor.getScreenLineCount() * 10 - - presenter = buildPresenter(explicitHeight: 500) - expect(getStylesForGutterWithName(presenter, 'line-number').scrollHeight).toBe 500 - expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollHeight).toBe 500 - - it "updates when the ::lineHeight changes", -> - presenter = buildPresenter() - expectStateUpdate presenter, -> presenter.setLineHeight(20) - expect(getStylesForGutterWithName(presenter, 'line-number').scrollHeight).toBe editor.getScreenLineCount() * 20 - expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollHeight).toBe editor.getScreenLineCount() * 20 - - it "updates when the line count changes", -> - presenter = buildPresenter() - expectStateUpdate presenter, -> editor.getBuffer().append("\n\n\n") - expect(getStylesForGutterWithName(presenter, 'line-number').scrollHeight).toBe editor.getScreenLineCount() * 10 - expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollHeight).toBe editor.getScreenLineCount() * 10 - - it "updates when ::explicitHeight changes", -> - presenter = buildPresenter() - expectStateUpdate presenter, -> presenter.setExplicitHeight(500) - expect(getStylesForGutterWithName(presenter, 'line-number').scrollHeight).toBe 500 - expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollHeight).toBe 500 - - it "adds the computed clientHeight to the computed scrollHeight if scrollPastEnd is enabled", -> - presenter = buildPresenter(scrollTop: 10, explicitHeight: 50, horizontalScrollbarHeight: 10) - expectStateUpdate presenter, -> presenter.setScrollTop(300) - expect(getStylesForGutterWithName(presenter, 'line-number').scrollHeight).toBe presenter.contentHeight - expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollHeight).toBe presenter.contentHeight - - expectStateUpdate presenter, -> editor.update({scrollPastEnd: true}) - expect(getStylesForGutterWithName(presenter, 'line-number').scrollHeight).toBe presenter.contentHeight + presenter.clientHeight - (presenter.lineHeight * 3) - expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollHeight).toBe presenter.contentHeight + presenter.clientHeight - (presenter.lineHeight * 3) - - expectStateUpdate presenter, -> editor.update({scrollPastEnd: false}) - expect(getStylesForGutterWithName(presenter, 'line-number').scrollHeight).toBe presenter.contentHeight - expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollHeight).toBe presenter.contentHeight - - describe ".scrollTop", -> - it "tracks the value of ::scrollTop", -> - presenter = buildPresenter(scrollTop: 10, explicitHeight: 20) - expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe 10 - expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe 10 - expectStateUpdate presenter, -> presenter.setScrollTop(50) - expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe 50 - expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe 50 - - it "never exceeds the computed scrollHeight minus the computed clientHeight", -> - presenter = buildPresenter(scrollTop: 10, explicitHeight: 50, horizontalScrollbarHeight: 10) - expectStateUpdate presenter, -> presenter.setScrollTop(100) - expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe presenter.scrollHeight - presenter.clientHeight - expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe presenter.scrollHeight - presenter.clientHeight - - expectStateUpdate presenter, -> presenter.setExplicitHeight(60) - expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe presenter.scrollHeight - presenter.clientHeight - expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe presenter.scrollHeight - presenter.clientHeight - - expectStateUpdate presenter, -> presenter.setHorizontalScrollbarHeight(5) - expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe presenter.scrollHeight - presenter.clientHeight - expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe presenter.scrollHeight - presenter.clientHeight - - expectStateUpdate presenter, -> editor.getBuffer().delete([[8, 0], [12, 0]]) - expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe presenter.scrollHeight - presenter.clientHeight - expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe presenter.scrollHeight - presenter.clientHeight - - # Scroll top only gets smaller when needed as dimensions change, never bigger - scrollTopBefore = getState(presenter).verticalScrollbar.scrollTop - expectStateUpdate presenter, -> editor.getBuffer().insert([9, Infinity], '\n\n\n') - expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe scrollTopBefore - expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe scrollTopBefore - - it "never goes negative", -> - presenter = buildPresenter(scrollTop: 10, explicitHeight: 50, horizontalScrollbarHeight: 10) - expectStateUpdate presenter, -> presenter.setScrollTop(-100) - expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe 0 - expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe 0 - - it "adds the computed clientHeight to the computed scrollHeight if scrollPastEnd is enabled", -> - presenter = buildPresenter(scrollTop: 10, explicitHeight: 50, horizontalScrollbarHeight: 10) - expectStateUpdate presenter, -> presenter.setScrollTop(300) - expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe presenter.contentHeight - presenter.clientHeight - expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe presenter.contentHeight - presenter.clientHeight - - editor.update({scrollPastEnd: true}) - expectStateUpdate presenter, -> presenter.setScrollTop(300) - expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe presenter.contentHeight - (presenter.lineHeight * 3) - expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe presenter.contentHeight - (presenter.lineHeight * 3) - - expectStateUpdate presenter, -> editor.update({scrollPastEnd: false}) - expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe presenter.contentHeight - presenter.clientHeight - expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe presenter.contentHeight - presenter.clientHeight - - describe ".backgroundColor", -> - it "is assigned to ::gutterBackgroundColor if present, and to ::backgroundColor otherwise", -> - presenter = buildPresenter() - presenter.setBackgroundColor("rgba(255, 0, 0, 0)") - presenter.setGutterBackgroundColor("rgba(0, 255, 0, 0)") - expect(getStylesForGutterWithName(presenter, 'line-number').backgroundColor).toBe "rgba(0, 255, 0, 0)" - expect(getStylesForGutterWithName(presenter, 'test-gutter').backgroundColor).toBe "rgba(0, 255, 0, 0)" - - expectStateUpdate presenter, -> presenter.setGutterBackgroundColor("rgba(0, 0, 255, 0)") - expect(getStylesForGutterWithName(presenter, 'line-number').backgroundColor).toBe "rgba(0, 0, 255, 0)" - expect(getStylesForGutterWithName(presenter, 'test-gutter').backgroundColor).toBe "rgba(0, 0, 255, 0)" - - expectStateUpdate presenter, -> presenter.setGutterBackgroundColor("rgba(0, 0, 0, 0)") - expect(getStylesForGutterWithName(presenter, 'line-number').backgroundColor).toBe "rgba(255, 0, 0, 0)" - expect(getStylesForGutterWithName(presenter, 'test-gutter').backgroundColor).toBe "rgba(255, 0, 0, 0)" - - expectStateUpdate presenter, -> presenter.setBackgroundColor("rgba(0, 0, 255, 0)") - expect(getStylesForGutterWithName(presenter, 'line-number').backgroundColor).toBe "rgba(0, 0, 255, 0)" - expect(getStylesForGutterWithName(presenter, 'test-gutter').backgroundColor).toBe "rgba(0, 0, 255, 0)" - - # disabled until we fix an issue with display buffer markers not updating when - # they are moved on screen but not in the buffer - xdescribe "when the model and view measurements are mutated randomly", -> - [editor, buffer, presenterParams, presenter, statements] = [] - - recordStatement = (statement) -> statements.push(statement) - - it "correctly maintains the presenter state", -> - _.times 20, -> - waits(0) - runs -> - performSetup() - performRandomInitialization(recordStatement) - _.times 20, -> - performRandomAction recordStatement - expectValidState() - performTeardown() - - xit "works correctly for a particular stream of random actions", -> - performSetup() - # paste output from failing spec here - expectValidState() - performTeardown() - - performSetup = -> - buffer = new TextBuffer - editor = new TextEditor({buffer}) - editor.setEditorWidthInChars(80) - presenterParams = - model: editor - - presenter = new TextEditorPresenter(presenterParams) - statements = [] - - performRandomInitialization = (log) -> - actions = _.shuffle([ - changeScrollLeft - changeScrollTop - changeExplicitHeight - changeContentFrameWidth - changeLineHeight - changeBaseCharacterWidth - changeHorizontalScrollbarHeight - changeVerticalScrollbarWidth - ]) - for action in actions - action(log) - expectValidState() - - performTeardown = -> - buffer.destroy() - - expectValidState = -> - presenterParams.scrollTop = presenter.scrollTop - presenterParams.scrollLeft = presenter.scrollLeft - actualState = getState(presenter) - expectedState = new TextEditorPresenter(presenterParams).state - delete actualState.content.scrollingVertically - delete expectedState.content.scrollingVertically - - unless _.isEqual(actualState, expectedState) - console.log "Presenter states differ >>>>>>>>>>>>>>>>" - console.log "Actual:", actualState - console.log "Expected:", expectedState - console.log "Uncomment code below this line to see a JSON diff" - # {diff} = require 'json-diff' # !!! Run `npm install json-diff` in your `atom/` repository - # console.log "Difference:", diff(actualState, expectedState) - if statements.length > 0 - console.log """ - ===================================================== - Paste this code into the disabled spec in this file (and enable it) to repeat this failure: - - #{statements.join('\n')} - ===================================================== - """ - throw new Error("Unexpected presenter state after random mutation. Check console output for details.") - - performRandomAction = (log) -> - getRandomElement([ - changeScrollLeft - changeScrollTop - toggleSoftWrap - insertText - changeCursors - changeSelections - changeLineDecorations - ])(log) - - changeScrollTop = (log) -> - scrollHeight = (presenterParams.lineHeight ? 10) * editor.getScreenLineCount() - explicitHeight = (presenterParams.explicitHeight ? 500) - newScrollTop = Math.max(0, _.random(0, scrollHeight - explicitHeight)) - log "presenter.setScrollTop(#{newScrollTop})" - presenter.setScrollTop(newScrollTop) - - changeScrollLeft = (log) -> - scrollWidth = presenter.scrollWidth ? 300 - contentFrameWidth = presenter.contentFrameWidth ? 200 - newScrollLeft = Math.max(0, _.random(0, scrollWidth - contentFrameWidth)) - log """ - presenterParams.scrollLeft = #{newScrollLeft} - presenter.setScrollLeft(#{newScrollLeft}) - """ - presenterParams.scrollLeft = newScrollLeft - presenter.setScrollLeft(newScrollLeft) - - changeExplicitHeight = (log) -> - scrollHeight = (presenterParams.lineHeight ? 10) * editor.getScreenLineCount() - newExplicitHeight = _.random(30, scrollHeight * 1.5) - log """ - presenterParams.explicitHeight = #{newExplicitHeight} - presenter.setExplicitHeight(#{newExplicitHeight}) - """ - presenterParams.explicitHeight = newExplicitHeight - presenter.setExplicitHeight(newExplicitHeight) - - changeContentFrameWidth = (log) -> - scrollWidth = presenter.scrollWidth ? 300 - newContentFrameWidth = _.random(100, scrollWidth * 1.5) - log """ - presenterParams.contentFrameWidth = #{newContentFrameWidth} - presenter.setContentFrameWidth(#{newContentFrameWidth}) - """ - presenterParams.contentFrameWidth = newContentFrameWidth - presenter.setContentFrameWidth(newContentFrameWidth) - - changeLineHeight = (log) -> - newLineHeight = _.random(5, 15) - log """ - presenterParams.lineHeight = #{newLineHeight} - presenter.setLineHeight(#{newLineHeight}) - """ - presenterParams.lineHeight = newLineHeight - presenter.setLineHeight(newLineHeight) - - changeBaseCharacterWidth = (log) -> - newBaseCharacterWidth = _.random(5, 15) - log """ - presenterParams.baseCharacterWidth = #{newBaseCharacterWidth} - presenter.setBaseCharacterWidth(#{newBaseCharacterWidth}) - """ - presenterParams.baseCharacterWidth = newBaseCharacterWidth - presenter.setBaseCharacterWidth(newBaseCharacterWidth) - - changeHorizontalScrollbarHeight = (log) -> - newHorizontalScrollbarHeight = _.random(2, 15) - log """ - presenterParams.horizontalScrollbarHeight = #{newHorizontalScrollbarHeight} - presenter.setHorizontalScrollbarHeight(#{newHorizontalScrollbarHeight}) - """ - presenterParams.horizontalScrollbarHeight = newHorizontalScrollbarHeight - presenter.setHorizontalScrollbarHeight(newHorizontalScrollbarHeight) - - changeVerticalScrollbarWidth = (log) -> - newVerticalScrollbarWidth = _.random(2, 15) - log """ - presenterParams.verticalScrollbarWidth = #{newVerticalScrollbarWidth} - presenter.setVerticalScrollbarWidth(#{newVerticalScrollbarWidth}) - """ - presenterParams.verticalScrollbarWidth = newVerticalScrollbarWidth - presenter.setVerticalScrollbarWidth(newVerticalScrollbarWidth) - - toggleSoftWrap = (log) -> - softWrapped = not editor.isSoftWrapped() - log "editor.setSoftWrapped(#{softWrapped})" - editor.update({softWrapped: softWrapped}) - - insertText = (log) -> - range = buildRandomRange() - text = buildRandomText() - log "editor.setTextInBufferRange(#{JSON.stringify(range.serialize())}, #{JSON.stringify(text)})" - editor.setTextInBufferRange(range, text) - - changeCursors = (log) -> - actions = [addCursor, moveCursor] - actions.push(destroyCursor) if editor.getCursors().length > 1 - getRandomElement(actions)(log) - - addCursor = (log) -> - position = buildRandomPoint() - log "editor.addCursorAtBufferPosition(#{JSON.stringify(position.serialize())})" - editor.addCursorAtBufferPosition(position) - - moveCursor = (log) -> - index = _.random(0, editor.getCursors().length - 1) - position = buildRandomPoint() - log """ - cursor = editor.getCursors()[#{index}] - cursor.selection.clear() - cursor.setBufferPosition(#{JSON.stringify(position.serialize())}) - """ - cursor = editor.getCursors()[index] - cursor.selection.clear() - cursor.setBufferPosition(position) - - destroyCursor = (log) -> - index = _.random(0, editor.getCursors().length - 1) - log "editor.getCursors()[#{index}].destroy()" - editor.getCursors()[index].destroy() - - changeSelections = (log) -> - actions = [addSelection, changeSelection] - actions.push(destroySelection) if editor.getSelections().length > 1 - getRandomElement(actions)(log) - - addSelection = (log) -> - range = buildRandomRange() - log "editor.addSelectionForBufferRange(#{JSON.stringify(range.serialize())})" - editor.addSelectionForBufferRange(range) - - changeSelection = (log) -> - index = _.random(0, editor.getSelections().length - 1) - range = buildRandomRange() - log "editor.getSelections()[#{index}].setBufferRange(#{JSON.stringify(range.serialize())})" - editor.getSelections()[index].setBufferRange(range) - - destroySelection = (log) -> - index = _.random(0, editor.getSelections().length - 1) - log "editor.getSelections()[#{index}].destroy()" - editor.getSelections()[index].destroy() - - changeLineDecorations = (log) -> - actions = [addLineDecoration] - actions.push(changeLineDecoration, destroyLineDecoration) if editor.getLineDecorations().length > 0 - getRandomElement(actions)(log) - - addLineDecoration = (log) -> - range = buildRandomRange() - options = { - type: getRandomElement(['line', 'line-number']) - class: randomWords(exactly: 1)[0] - } - if Math.random() > .2 - options.onlyEmpty = true - else if Math.random() > .2 - options.onlyNonEmpty = true - else if Math.random() > .2 - options.onlyHead = true - - log """ - marker = editor.markBufferRange(#{JSON.stringify(range.serialize())}) - editor.decorateMarker(marker, #{JSON.stringify(options)}) - """ - - marker = editor.markBufferRange(range) - editor.decorateMarker(marker, options) - - changeLineDecoration = (log) -> - index = _.random(0, editor.getLineDecorations().length - 1) - range = buildRandomRange() - log "editor.getLineDecorations()[#{index}].getMarker().setBufferRange(#{JSON.stringify(range.serialize())})" - editor.getLineDecorations()[index].getMarker().setBufferRange(range) - - destroyLineDecoration = (log) -> - index = _.random(0, editor.getLineDecorations().length - 1) - log "editor.getLineDecorations()[#{index}].destroy()" - editor.getLineDecorations()[index].destroy() - - buildRandomPoint = -> - row = _.random(0, buffer.getLastRow()) - column = _.random(0, buffer.lineForRow(row).length) - new Point(row, column) - - buildRandomRange = -> - new Range(buildRandomPoint(), buildRandomPoint()) - - buildRandomText = -> - text = [] - - _.times _.random(20, 60), -> - if Math.random() < .2 - text += '\n' - else - text += " " if /\w$/.test(text) - text += randomWords(exactly: 1) - text - - getRandomElement = (array) -> - array[Math.floor(Math.random() * array.length)] diff --git a/src/cursors-component.coffee b/src/cursors-component.coffee deleted file mode 100644 index 2de4f1ede76..00000000000 --- a/src/cursors-component.coffee +++ /dev/null @@ -1,58 +0,0 @@ -module.exports = -class CursorsComponent - oldState: null - - constructor: -> - @cursorNodesById = {} - @domNode = document.createElement('div') - @domNode.classList.add('cursors') - - getDomNode: -> - @domNode - - updateSync: (state) -> - newState = state.content - @oldState ?= {cursors: {}} - - # update blink class - if newState.cursorsVisible isnt @oldState.cursorsVisible - if newState.cursorsVisible - @domNode.classList.remove 'blink-off' - else - @domNode.classList.add 'blink-off' - @oldState.cursorsVisible = newState.cursorsVisible - - # remove cursors - for id of @oldState.cursors - unless newState.cursors[id]? - @cursorNodesById[id].remove() - delete @cursorNodesById[id] - delete @oldState.cursors[id] - - # add or update cursors - for id, cursorState of newState.cursors - unless @oldState.cursors[id]? - cursorNode = document.createElement('div') - cursorNode.classList.add('cursor') - @cursorNodesById[id] = cursorNode - @domNode.appendChild(cursorNode) - @updateCursorNode(id, cursorState) - - return - - updateCursorNode: (id, newCursorState) -> - cursorNode = @cursorNodesById[id] - oldCursorState = (@oldState.cursors[id] ?= {}) - - if newCursorState.top isnt oldCursorState.top or newCursorState.left isnt oldCursorState.left - cursorNode.style['-webkit-transform'] = "translate(#{newCursorState.left}px, #{newCursorState.top}px)" - oldCursorState.top = newCursorState.top - oldCursorState.left = newCursorState.left - - if newCursorState.height isnt oldCursorState.height - cursorNode.style.height = newCursorState.height + 'px' - oldCursorState.height = newCursorState.height - - if newCursorState.width isnt oldCursorState.width - cursorNode.style.width = newCursorState.width + 'px' - oldCursorState.width = newCursorState.width diff --git a/src/custom-gutter-component.coffee b/src/custom-gutter-component.coffee deleted file mode 100644 index 759fcf2c4c4..00000000000 --- a/src/custom-gutter-component.coffee +++ /dev/null @@ -1,119 +0,0 @@ -# This class represents a gutter other than the 'line-numbers' gutter. -# The contents of this gutter may be specified by Decorations. - -module.exports = -class CustomGutterComponent - constructor: ({@gutter, @views}) -> - @decorationNodesById = {} - @decorationItemsById = {} - @visible = true - - @domNode = @gutter.getElement() - @decorationsNode = @domNode.firstChild - # Clear the contents in case the domNode is being reused. - @decorationsNode.innerHTML = '' - - getDomNode: -> - @domNode - - hideNode: -> - if @visible - @domNode.style.display = 'none' - @visible = false - - showNode: -> - if not @visible - @domNode.style.removeProperty('display') - @visible = true - - # `state` is a subset of the TextEditorPresenter state that is specific - # to this line number gutter. - updateSync: (state) -> - @oldDimensionsAndBackgroundState ?= {} - setDimensionsAndBackground(@oldDimensionsAndBackgroundState, state.styles, @decorationsNode) - - @oldDecorationPositionState ?= {} - decorationState = state.content - - updatedDecorationIds = new Set - for decorationId, decorationInfo of decorationState - updatedDecorationIds.add(decorationId) - existingDecoration = @decorationNodesById[decorationId] - if existingDecoration - @updateDecorationNode(existingDecoration, decorationId, decorationInfo) - else - newNode = @buildDecorationNode(decorationId, decorationInfo) - @decorationNodesById[decorationId] = newNode - @decorationsNode.appendChild(newNode) - - for decorationId, decorationNode of @decorationNodesById - if not updatedDecorationIds.has(decorationId) - decorationNode.remove() - delete @decorationNodesById[decorationId] - delete @decorationItemsById[decorationId] - delete @oldDecorationPositionState[decorationId] - - ### - Section: Private Methods - ### - - # Builds and returns an HTMLElement to represent the specified decoration. - buildDecorationNode: (decorationId, decorationInfo) -> - @oldDecorationPositionState[decorationId] = {} - newNode = document.createElement('div') - newNode.style.position = 'absolute' - @updateDecorationNode(newNode, decorationId, decorationInfo) - newNode - - # Updates the existing HTMLNode with the new decoration info. Attempts to - # minimize changes to the DOM. - updateDecorationNode: (node, decorationId, newDecorationInfo) -> - oldPositionState = @oldDecorationPositionState[decorationId] - - if oldPositionState.top isnt newDecorationInfo.top + 'px' - node.style.top = newDecorationInfo.top + 'px' - oldPositionState.top = newDecorationInfo.top + 'px' - - if oldPositionState.height isnt newDecorationInfo.height + 'px' - node.style.height = newDecorationInfo.height + 'px' - oldPositionState.height = newDecorationInfo.height + 'px' - - if newDecorationInfo.class and not node.classList.contains(newDecorationInfo.class) - node.className = 'decoration' - node.classList.add(newDecorationInfo.class) - else if not newDecorationInfo.class - node.className = 'decoration' - - @setDecorationItem(newDecorationInfo.item, newDecorationInfo.height, decorationId, node) - - # Sets the decorationItem on the decorationNode. - # If `decorationItem` is undefined, the decorationNode's child item will be cleared. - setDecorationItem: (newItem, decorationHeight, decorationId, decorationNode) -> - if newItem isnt @decorationItemsById[decorationId] - while decorationNode.firstChild - decorationNode.removeChild(decorationNode.firstChild) - delete @decorationItemsById[decorationId] - - if newItem - newItemNode = null - if newItem instanceof HTMLElement - newItemNode = newItem - else - newItemNode = newItem.element - - newItemNode.style.height = decorationHeight + 'px' - decorationNode.appendChild(newItemNode) - @decorationItemsById[decorationId] = newItem - -setDimensionsAndBackground = (oldState, newState, domNode) -> - if newState.scrollHeight isnt oldState.scrollHeight - domNode.style.height = newState.scrollHeight + 'px' - oldState.scrollHeight = newState.scrollHeight - - if newState.scrollTop isnt oldState.scrollTop - domNode.style['-webkit-transform'] = "translate3d(0px, #{-newState.scrollTop}px, 0px)" - oldState.scrollTop = newState.scrollTop - - if newState.backgroundColor isnt oldState.backgroundColor - domNode.style.backgroundColor = newState.backgroundColor - oldState.backgroundColor = newState.backgroundColor diff --git a/src/gutter-container-component.coffee b/src/gutter-container-component.coffee deleted file mode 100644 index ebb2d8597de..00000000000 --- a/src/gutter-container-component.coffee +++ /dev/null @@ -1,112 +0,0 @@ -_ = require 'underscore-plus' -CustomGutterComponent = require './custom-gutter-component' -LineNumberGutterComponent = require './line-number-gutter-component' - -# The GutterContainerComponent manages the GutterComponents of a particular -# TextEditorComponent. - -module.exports = -class GutterContainerComponent - constructor: ({@onLineNumberGutterMouseDown, @editor, @domElementPool, @views}) -> - # An array of objects of the form: {name: {String}, component: {Object}} - @gutterComponents = [] - @gutterComponentsByGutterName = {} - @lineNumberGutterComponent = null - - @domNode = document.createElement('div') - @domNode.classList.add('gutter-container') - @domNode.style.display = 'flex' - - destroy: -> - for {component} in @gutterComponents - component.destroy?() - return - - getDomNode: -> - @domNode - - getLineNumberGutterComponent: -> - @lineNumberGutterComponent - - updateSync: (state) -> - # The GutterContainerComponent expects the gutters to be sorted in the order - # they should appear. - newState = state.gutters - - newGutterComponents = [] - newGutterComponentsByGutterName = {} - for {gutter, visible, styles, content} in newState - gutterComponent = @gutterComponentsByGutterName[gutter.name] - if not gutterComponent - if gutter.name is 'line-number' - gutterComponent = new LineNumberGutterComponent({onMouseDown: @onLineNumberGutterMouseDown, @editor, gutter, @domElementPool, @views}) - @lineNumberGutterComponent = gutterComponent - else - gutterComponent = new CustomGutterComponent({gutter, @views}) - - if visible then gutterComponent.showNode() else gutterComponent.hideNode() - # Pass the gutter only the state that it needs. - if gutter.name is 'line-number' - # For ease of use in the line number gutter component, set the shared - # 'styles' as a field under the 'content'. - gutterSubstate = _.clone(content) - gutterSubstate.styles = styles - else - # Custom gutter 'content' is keyed on gutter name, so we cannot set - # 'styles' as a subfield directly under it. - gutterSubstate = {content, styles} - gutterComponent.updateSync(gutterSubstate) - - newGutterComponents.push({ - name: gutter.name, - component: gutterComponent, - }) - newGutterComponentsByGutterName[gutter.name] = gutterComponent - - @reorderGutters(newGutterComponents, newGutterComponentsByGutterName) - - @gutterComponents = newGutterComponents - @gutterComponentsByGutterName = newGutterComponentsByGutterName - - ### - Section: Private Methods - ### - - reorderGutters: (newGutterComponents, newGutterComponentsByGutterName) -> - # First, insert new gutters into the DOM. - indexInOldGutters = 0 - oldGuttersLength = @gutterComponents.length - - for gutterComponentDescription in newGutterComponents - gutterComponent = gutterComponentDescription.component - gutterName = gutterComponentDescription.name - - if @gutterComponentsByGutterName[gutterName] - # If the gutter existed previously, we first try to move the cursor to - # the point at which it occurs in the previous gutters. - matchingGutterFound = false - while indexInOldGutters < oldGuttersLength - existingGutterComponentDescription = @gutterComponents[indexInOldGutters] - existingGutterComponent = existingGutterComponentDescription.component - indexInOldGutters++ - if existingGutterComponent is gutterComponent - matchingGutterFound = true - break - if not matchingGutterFound - # If we've reached this point, the gutter previously existed, but its - # position has moved. Remove it from the DOM and re-insert it. - gutterComponent.getDomNode().remove() - @domNode.appendChild(gutterComponent.getDomNode()) - - else - if indexInOldGutters is oldGuttersLength - @domNode.appendChild(gutterComponent.getDomNode()) - else - @domNode.insertBefore(gutterComponent.getDomNode(), @domNode.children[indexInOldGutters]) - indexInOldGutters += 1 - - # Remove any gutters that were not present in the new gutters state. - for gutterComponentDescription in @gutterComponents - if not newGutterComponentsByGutterName[gutterComponentDescription.name] - gutterComponent = gutterComponentDescription.component - gutterComponent.getDomNode().remove() diff --git a/src/highlights-component.coffee b/src/highlights-component.coffee deleted file mode 100644 index e5c1db60ee8..00000000000 --- a/src/highlights-component.coffee +++ /dev/null @@ -1,119 +0,0 @@ -RegionStyleProperties = ['top', 'left', 'right', 'width', 'height'] -SpaceRegex = /\s+/ - -module.exports = -class HighlightsComponent - oldState: null - - constructor: (@domElementPool) -> - @highlightNodesById = {} - @regionNodesByHighlightId = {} - - @domNode = @domElementPool.buildElement("div", "highlights") - - getDomNode: -> - @domNode - - updateSync: (state) -> - newState = state.highlights - @oldState ?= {} - - # remove highlights - for id of @oldState - unless newState[id]? - @domElementPool.freeElementAndDescendants(@highlightNodesById[id]) - delete @highlightNodesById[id] - delete @regionNodesByHighlightId[id] - delete @oldState[id] - - # add or update highlights - for id, highlightState of newState - unless @oldState[id]? - highlightNode = @domElementPool.buildElement("div", "highlight") - @highlightNodesById[id] = highlightNode - @regionNodesByHighlightId[id] = {} - @domNode.appendChild(highlightNode) - @updateHighlightNode(id, highlightState) - - return - - updateHighlightNode: (id, newHighlightState) -> - highlightNode = @highlightNodesById[id] - oldHighlightState = (@oldState[id] ?= {regions: [], flashCount: 0}) - - # update class - if newHighlightState.class isnt oldHighlightState.class - if oldHighlightState.class? - if SpaceRegex.test(oldHighlightState.class) - highlightNode.classList.remove(oldHighlightState.class.split(SpaceRegex)...) - else - highlightNode.classList.remove(oldHighlightState.class) - - if SpaceRegex.test(newHighlightState.class) - highlightNode.classList.add(newHighlightState.class.split(SpaceRegex)...) - else - highlightNode.classList.add(newHighlightState.class) - - oldHighlightState.class = newHighlightState.class - - @updateHighlightRegions(id, newHighlightState) - @flashHighlightNodeIfRequested(id, newHighlightState) - - updateHighlightRegions: (id, newHighlightState) -> - oldHighlightState = @oldState[id] - highlightNode = @highlightNodesById[id] - - # remove regions - while oldHighlightState.regions.length > newHighlightState.regions.length - oldHighlightState.regions.pop() - @domElementPool.freeElementAndDescendants(@regionNodesByHighlightId[id][oldHighlightState.regions.length]) - delete @regionNodesByHighlightId[id][oldHighlightState.regions.length] - - # add or update regions - for newRegionState, i in newHighlightState.regions - unless oldHighlightState.regions[i]? - oldHighlightState.regions[i] = {} - regionNode = @domElementPool.buildElement("div", "region") - # This prevents highlights at the tiles boundaries to be hidden by the - # subsequent tile. When this happens, subpixel anti-aliasing gets - # disabled. - regionNode.style.boxSizing = "border-box" - regionNode.classList.add(newHighlightState.deprecatedRegionClass) if newHighlightState.deprecatedRegionClass? - @regionNodesByHighlightId[id][i] = regionNode - highlightNode.appendChild(regionNode) - - oldRegionState = oldHighlightState.regions[i] - regionNode = @regionNodesByHighlightId[id][i] - - for property in RegionStyleProperties - if newRegionState[property] isnt oldRegionState[property] - oldRegionState[property] = newRegionState[property] - if newRegionState[property]? - regionNode.style[property] = newRegionState[property] + 'px' - else - regionNode.style[property] = '' - - return - - flashHighlightNodeIfRequested: (id, newHighlightState) -> - oldHighlightState = @oldState[id] - if newHighlightState.needsFlash and oldHighlightState.flashCount isnt newHighlightState.flashCount - highlightNode = @highlightNodesById[id] - - addFlashClass = => - highlightNode.classList.add(newHighlightState.flashClass) - oldHighlightState.flashClass = newHighlightState.flashClass - @flashTimeoutId = setTimeout(removeFlashClass, newHighlightState.flashDuration) - - removeFlashClass = => - highlightNode.classList.remove(oldHighlightState.flashClass) - oldHighlightState.flashClass = null - clearTimeout(@flashTimeoutId) - - if oldHighlightState.flashClass? - removeFlashClass() - requestAnimationFrame(addFlashClass) - else - addFlashClass() - - oldHighlightState.flashCount = newHighlightState.flashCount diff --git a/src/input-component.coffee b/src/input-component.coffee deleted file mode 100644 index 27543a2fd6a..00000000000 --- a/src/input-component.coffee +++ /dev/null @@ -1,23 +0,0 @@ -module.exports = -class InputComponent - constructor: (@domNode) -> - - updateSync: (state) -> - @oldState ?= {} - newState = state.hiddenInput - - if newState.top isnt @oldState.top - @domNode.style.top = newState.top + 'px' - @oldState.top = newState.top - - if newState.left isnt @oldState.left - @domNode.style.left = newState.left + 'px' - @oldState.left = newState.left - - if newState.width isnt @oldState.width - @domNode.style.width = newState.width + 'px' - @oldState.width = newState.width - - if newState.height isnt @oldState.height - @domNode.style.height = newState.height + 'px' - @oldState.height = newState.height diff --git a/src/line-number-gutter-component.coffee b/src/line-number-gutter-component.coffee deleted file mode 100644 index f0f150ff21e..00000000000 --- a/src/line-number-gutter-component.coffee +++ /dev/null @@ -1,99 +0,0 @@ -TiledComponent = require './tiled-component' -LineNumbersTileComponent = require './line-numbers-tile-component' - -module.exports = -class LineNumberGutterComponent extends TiledComponent - dummyLineNumberNode: null - - constructor: ({@onMouseDown, @editor, @gutter, @domElementPool, @views}) -> - @visible = true - - @dummyLineNumberComponent = LineNumbersTileComponent.createDummy(@domElementPool) - - @domNode = @gutter.getElement() - @lineNumbersNode = @domNode.firstChild - @lineNumbersNode.innerHTML = '' - - @domNode.addEventListener 'click', @onClick - @domNode.addEventListener 'mousedown', @onMouseDown - - destroy: -> - @domNode.removeEventListener 'click', @onClick - @domNode.removeEventListener 'mousedown', @onMouseDown - - getDomNode: -> - @domNode - - hideNode: -> - if @visible - @domNode.style.display = 'none' - @visible = false - - showNode: -> - if not @visible - @domNode.style.removeProperty('display') - @visible = true - - buildEmptyState: -> - { - tiles: {} - styles: {} - } - - getNewState: (state) -> state - - getTilesNode: -> @lineNumbersNode - - beforeUpdateSync: (state) -> - @appendDummyLineNumber() unless @dummyLineNumberNode? - - if @newState.styles.maxHeight isnt @oldState.styles.maxHeight - @lineNumbersNode.style.height = @newState.styles.maxHeight + 'px' - @oldState.maxHeight = @newState.maxHeight - - if @newState.styles.backgroundColor isnt @oldState.styles.backgroundColor - @lineNumbersNode.style.backgroundColor = @newState.styles.backgroundColor - @oldState.styles.backgroundColor = @newState.styles.backgroundColor - - if @newState.maxLineNumberDigits isnt @oldState.maxLineNumberDigits - @updateDummyLineNumber() - @oldState.styles = {} - @oldState.maxLineNumberDigits = @newState.maxLineNumberDigits - - buildComponentForTile: (id) -> new LineNumbersTileComponent({id, @domElementPool}) - - shouldRecreateAllTilesOnUpdate: -> - @newState.continuousReflow - - ### - Section: Private Methods - ### - - # This dummy line number element holds the gutter to the appropriate width, - # since the real line numbers are absolutely positioned for performance reasons. - appendDummyLineNumber: -> - @dummyLineNumberComponent.newState = @newState - @dummyLineNumberNode = @dummyLineNumberComponent.buildLineNumberNode({bufferRow: -1}) - @lineNumbersNode.appendChild(@dummyLineNumberNode) - - updateDummyLineNumber: -> - @dummyLineNumberComponent.newState = @newState - @dummyLineNumberComponent.setLineNumberInnerNodes(0, false, @dummyLineNumberNode) - - onMouseDown: (event) => - {target} = event - lineNumber = target.parentNode - - unless target.classList.contains('icon-right') and lineNumber.classList.contains('foldable') - @onMouseDown(event) - - onClick: (event) => - {target} = event - lineNumber = target.parentNode - - if target.classList.contains('icon-right') - bufferRow = parseInt(lineNumber.getAttribute('data-buffer-row')) - if lineNumber.classList.contains('folded') - @editor.unfoldBufferRow(bufferRow) - else if lineNumber.classList.contains('foldable') - @editor.foldBufferRow(bufferRow) diff --git a/src/line-numbers-tile-component.coffee b/src/line-numbers-tile-component.coffee deleted file mode 100644 index ba62af3f84a..00000000000 --- a/src/line-numbers-tile-component.coffee +++ /dev/null @@ -1,158 +0,0 @@ -_ = require 'underscore-plus' - -module.exports = -class LineNumbersTileComponent - @createDummy: (domElementPool) -> - new LineNumbersTileComponent({id: -1, domElementPool}) - - constructor: ({@id, @domElementPool}) -> - @lineNumberNodesById = {} - @domNode = @domElementPool.buildElement("div") - @domNode.style.position = "absolute" - @domNode.style.display = "block" - @domNode.style.top = 0 # Cover the space occupied by a dummy lineNumber - @domNode.style.backgroundColor = "inherit" - - destroy: -> - @domElementPool.freeElementAndDescendants(@domNode) - - getDomNode: -> - @domNode - - updateSync: (state) -> - @newState = state - unless @oldState - @oldState = {tiles: {}, styles: {}} - @oldState.tiles[@id] = {lineNumbers: {}} - - @newTileState = @newState.tiles[@id] - @oldTileState = @oldState.tiles[@id] - - if @newTileState.display isnt @oldTileState.display - @domNode.style.display = @newTileState.display - @oldTileState.display = @newTileState.display - - if @newState.styles.backgroundColor isnt @oldState.styles.backgroundColor - @domNode.style.backgroundColor = @newState.styles.backgroundColor - @oldState.styles.backgroundColor = @newState.styles.backgroundColor - - if @newTileState.height isnt @oldTileState.height - @domNode.style.height = @newTileState.height + 'px' - @oldTileState.height = @newTileState.height - - if @newTileState.top isnt @oldTileState.top - @domNode.style['-webkit-transform'] = "translate3d(0, #{@newTileState.top}px, 0px)" - @oldTileState.top = @newTileState.top - - if @newTileState.zIndex isnt @oldTileState.zIndex - @domNode.style.zIndex = @newTileState.zIndex - @oldTileState.zIndex = @newTileState.zIndex - - if @newState.maxLineNumberDigits isnt @oldState.maxLineNumberDigits - for id, node of @lineNumberNodesById - @domElementPool.freeElementAndDescendants(node) - - @oldState.tiles[@id] = {lineNumbers: {}} - @oldTileState = @oldState.tiles[@id] - @lineNumberNodesById = {} - @oldState.maxLineNumberDigits = @newState.maxLineNumberDigits - - @updateLineNumbers() - - updateLineNumbers: -> - newLineNumberIds = null - newLineNumberNodes = null - - for id, lineNumberState of @oldTileState.lineNumbers - unless @newTileState.lineNumbers.hasOwnProperty(id) - @domElementPool.freeElementAndDescendants(@lineNumberNodesById[id]) - delete @lineNumberNodesById[id] - delete @oldTileState.lineNumbers[id] - - for id, lineNumberState of @newTileState.lineNumbers - if @oldTileState.lineNumbers.hasOwnProperty(id) - @updateLineNumberNode(id, lineNumberState) - else - newLineNumberIds ?= [] - newLineNumberNodes ?= [] - newLineNumberIds.push(id) - newLineNumberNodes.push(@buildLineNumberNode(lineNumberState)) - @oldTileState.lineNumbers[id] = _.clone(lineNumberState) - - return unless newLineNumberIds? - - for id, i in newLineNumberIds - lineNumberNode = newLineNumberNodes[i] - @lineNumberNodesById[id] = lineNumberNode - if nextNode = @findNodeNextTo(lineNumberNode) - @domNode.insertBefore(lineNumberNode, nextNode) - else - @domNode.appendChild(lineNumberNode) - - findNodeNextTo: (node) -> - for nextNode in @domNode.children - return nextNode if @screenRowForNode(node) < @screenRowForNode(nextNode) - return - - screenRowForNode: (node) -> parseInt(node.dataset.screenRow) - - buildLineNumberNode: (lineNumberState) -> - {screenRow, bufferRow, softWrapped, blockDecorationsHeight} = lineNumberState - - className = @buildLineNumberClassName(lineNumberState) - lineNumberNode = @domElementPool.buildElement("div", className) - lineNumberNode.dataset.screenRow = screenRow - lineNumberNode.dataset.bufferRow = bufferRow - lineNumberNode.style.marginTop = blockDecorationsHeight + "px" - - @setLineNumberInnerNodes(bufferRow, softWrapped, lineNumberNode) - lineNumberNode - - setLineNumberInnerNodes: (bufferRow, softWrapped, lineNumberNode) -> - @domElementPool.freeDescendants(lineNumberNode) - - {maxLineNumberDigits} = @newState - - if softWrapped - lineNumber = "•" - else - lineNumber = (bufferRow + 1).toString() - padding = _.multiplyString("\u00a0", maxLineNumberDigits - lineNumber.length) - - textNode = @domElementPool.buildText(padding + lineNumber) - iconRight = @domElementPool.buildElement("div", "icon-right") - - lineNumberNode.appendChild(textNode) - lineNumberNode.appendChild(iconRight) - - updateLineNumberNode: (lineNumberId, newLineNumberState) -> - oldLineNumberState = @oldTileState.lineNumbers[lineNumberId] - node = @lineNumberNodesById[lineNumberId] - - unless oldLineNumberState.foldable is newLineNumberState.foldable and _.isEqual(oldLineNumberState.decorationClasses, newLineNumberState.decorationClasses) - node.className = @buildLineNumberClassName(newLineNumberState) - oldLineNumberState.foldable = newLineNumberState.foldable - oldLineNumberState.decorationClasses = _.clone(newLineNumberState.decorationClasses) - - unless oldLineNumberState.screenRow is newLineNumberState.screenRow and oldLineNumberState.bufferRow is newLineNumberState.bufferRow - @setLineNumberInnerNodes(newLineNumberState.bufferRow, newLineNumberState.softWrapped, node) - node.dataset.screenRow = newLineNumberState.screenRow - node.dataset.bufferRow = newLineNumberState.bufferRow - oldLineNumberState.screenRow = newLineNumberState.screenRow - oldLineNumberState.bufferRow = newLineNumberState.bufferRow - - unless oldLineNumberState.blockDecorationsHeight is newLineNumberState.blockDecorationsHeight - node.style.marginTop = newLineNumberState.blockDecorationsHeight + "px" - oldLineNumberState.blockDecorationsHeight = newLineNumberState.blockDecorationsHeight - - buildLineNumberClassName: ({bufferRow, foldable, decorationClasses, softWrapped}) -> - className = "line-number" - className += " " + decorationClasses.join(' ') if decorationClasses? - className += " foldable" if foldable and not softWrapped - className - - lineNumberNodeForScreenRow: (screenRow) -> - for id, lineNumberState of @oldTileState.lineNumbers - if lineNumberState.screenRow is screenRow - return @lineNumberNodesById[id] - null diff --git a/src/lines-component.coffee b/src/lines-component.coffee deleted file mode 100644 index a3028771479..00000000000 --- a/src/lines-component.coffee +++ /dev/null @@ -1,110 +0,0 @@ -CursorsComponent = require './cursors-component' -LinesTileComponent = require './lines-tile-component' -TiledComponent = require './tiled-component' - -module.exports = -class LinesComponent extends TiledComponent - placeholderTextDiv: null - - constructor: ({@views, @presenter, @domElementPool, @assert}) -> - @DummyLineNode = document.createElement('div') - @DummyLineNode.className = 'line' - @DummyLineNode.style.position = 'absolute' - @DummyLineNode.style.visibility = 'hidden' - @DummyLineNode.appendChild(document.createElement('span')) - @DummyLineNode.appendChild(document.createElement('span')) - @DummyLineNode.appendChild(document.createElement('span')) - @DummyLineNode.appendChild(document.createElement('span')) - @DummyLineNode.children[0].textContent = 'x' - @DummyLineNode.children[1].textContent = '我' - @DummyLineNode.children[2].textContent = 'ハ' - @DummyLineNode.children[3].textContent = '세' - - @domNode = document.createElement('div') - @domNode.classList.add('lines') - @tilesNode = document.createElement("div") - # Create a new stacking context, so that tiles z-index does not interfere - # with other visual elements. - @tilesNode.style.isolation = "isolate" - @tilesNode.style.zIndex = 0 - @tilesNode.style.backgroundColor = "inherit" - @domNode.appendChild(@tilesNode) - - @cursorsComponent = new CursorsComponent - @domNode.appendChild(@cursorsComponent.getDomNode()) - - getDomNode: -> - @domNode - - shouldRecreateAllTilesOnUpdate: -> - @newState.continuousReflow - - beforeUpdateSync: (state) -> - if @newState.maxHeight isnt @oldState.maxHeight - @domNode.style.height = @newState.maxHeight + 'px' - @oldState.maxHeight = @newState.maxHeight - - if @newState.backgroundColor isnt @oldState.backgroundColor - @domNode.style.backgroundColor = @newState.backgroundColor - @oldState.backgroundColor = @newState.backgroundColor - - afterUpdateSync: (state) -> - if @newState.placeholderText isnt @oldState.placeholderText - @placeholderTextDiv?.remove() - if @newState.placeholderText? - @placeholderTextDiv = document.createElement('div') - @placeholderTextDiv.classList.add('placeholder-text') - @placeholderTextDiv.textContent = @newState.placeholderText - @domNode.appendChild(@placeholderTextDiv) - @oldState.placeholderText = @newState.placeholderText - - # Removing and updating block decorations needs to be done in two different - # steps, so that the same decoration node can be moved from one tile to - # another in the same animation frame. - for component in @getComponents() - component.removeDeletedBlockDecorations() - for component in @getComponents() - component.updateBlockDecorations() - - @cursorsComponent.updateSync(state) - - buildComponentForTile: (id) -> new LinesTileComponent({id, @presenter, @domElementPool, @assert, @views}) - - buildEmptyState: -> - {tiles: {}} - - getNewState: (state) -> - state.content - - getTilesNode: -> @tilesNode - - measureLineHeightAndDefaultCharWidth: -> - @domNode.appendChild(@DummyLineNode) - - lineHeightInPixels = @DummyLineNode.getBoundingClientRect().height - defaultCharWidth = @DummyLineNode.children[0].getBoundingClientRect().width - doubleWidthCharWidth = @DummyLineNode.children[1].getBoundingClientRect().width - halfWidthCharWidth = @DummyLineNode.children[2].getBoundingClientRect().width - koreanCharWidth = @DummyLineNode.children[3].getBoundingClientRect().width - - @domNode.removeChild(@DummyLineNode) - - @presenter.setLineHeight(lineHeightInPixels) - @presenter.setBaseCharacterWidth(defaultCharWidth, doubleWidthCharWidth, halfWidthCharWidth, koreanCharWidth) - - measureBlockDecorations: -> - for component in @getComponents() - component.measureBlockDecorations() - return - - lineIdForScreenRow: (screenRow) -> - tile = @presenter.tileForRow(screenRow) - @getComponentForTile(tile)?.lineIdForScreenRow(screenRow) - - lineNodeForScreenRow: (screenRow) -> - tile = @presenter.tileForRow(screenRow) - @getComponentForTile(tile)?.lineNodeForScreenRow(screenRow) - - textNodesForScreenRow: (screenRow) -> - tile = @presenter.tileForRow(screenRow) - @getComponentForTile(tile)?.textNodesForScreenRow(screenRow) diff --git a/src/lines-tile-component.js b/src/lines-tile-component.js deleted file mode 100644 index 9bfdf738262..00000000000 --- a/src/lines-tile-component.js +++ /dev/null @@ -1,402 +0,0 @@ -const HighlightsComponent = require('./highlights-component') -const ZERO_WIDTH_NBSP = '\ufeff' - -module.exports = class LinesTileComponent { - constructor ({presenter, id, domElementPool, assert, views}) { - this.id = id - this.presenter = presenter - this.views = views - this.domElementPool = domElementPool - this.assert = assert - this.lineNodesByLineId = {} - this.screenRowsByLineId = {} - this.lineIdsByScreenRow = {} - this.textNodesByLineId = {} - this.blockDecorationNodesByLineIdAndDecorationId = {} - this.domNode = this.domElementPool.buildElement('div') - this.domNode.style.position = 'absolute' - this.domNode.style.display = 'block' - this.domNode.style.backgroundColor = 'inherit' - this.highlightsComponent = new HighlightsComponent(this.domElementPool) - this.domNode.appendChild(this.highlightsComponent.getDomNode()) - } - - destroy () { - this.removeLineNodes() - this.domElementPool.freeElementAndDescendants(this.domNode) - } - - getDomNode () { - return this.domNode - } - - updateSync (state) { - this.newState = state - if (this.oldState == null) { - this.oldState = {tiles: {}} - this.oldState.tiles[this.id] = {lines: {}} - } - - this.newTileState = this.newState.tiles[this.id] - this.oldTileState = this.oldState.tiles[this.id] - - if (this.newState.backgroundColor !== this.oldState.backgroundColor) { - this.domNode.style.backgroundColor = this.newState.backgroundColor - this.oldState.backgroundColor = this.newState.backgroundColor - } - - if (this.newTileState.zIndex !== this.oldTileState.zIndex) { - this.domNode.style.zIndex = this.newTileState.zIndex - this.oldTileState.zIndex = this.newTileState.zIndex - } - - if (this.newTileState.display !== this.oldTileState.display) { - this.domNode.style.display = this.newTileState.display - this.oldTileState.display = this.newTileState.display - } - - if (this.newTileState.height !== this.oldTileState.height) { - this.domNode.style.height = this.newTileState.height + 'px' - this.oldTileState.height = this.newTileState.height - } - - if (this.newState.width !== this.oldState.width) { - this.domNode.style.width = this.newState.width + 'px' - this.oldState.width = this.newState.width - } - - if (this.newTileState.top !== this.oldTileState.top || this.newTileState.left !== this.oldTileState.left) { - this.domNode.style.transform = `translate3d(${this.newTileState.left}px, ${this.newTileState.top}px, 0px)` - this.oldTileState.top = this.newTileState.top - this.oldTileState.left = this.newTileState.left - } - - this.updateLineNodes() - this.highlightsComponent.updateSync(this.newTileState) - } - - removeLineNodes () { - for (const id of Object.keys(this.oldTileState.lines)) { - this.removeLineNode(id) - } - } - - removeLineNode (lineId) { - this.domElementPool.freeElementAndDescendants(this.lineNodesByLineId[lineId]) - for (const decorationId of Object.keys(this.oldTileState.lines[lineId].precedingBlockDecorations)) { - const {topRulerNode, blockDecorationNode, bottomRulerNode} = - this.blockDecorationNodesByLineIdAndDecorationId[lineId][decorationId] - topRulerNode.remove() - blockDecorationNode.remove() - bottomRulerNode.remove() - } - for (const decorationId of Object.keys(this.oldTileState.lines[lineId].followingBlockDecorations)) { - const {topRulerNode, blockDecorationNode, bottomRulerNode} = - this.blockDecorationNodesByLineIdAndDecorationId[lineId][decorationId] - topRulerNode.remove() - blockDecorationNode.remove() - bottomRulerNode.remove() - } - - delete this.blockDecorationNodesByLineIdAndDecorationId[lineId] - delete this.lineNodesByLineId[lineId] - delete this.textNodesByLineId[lineId] - delete this.lineIdsByScreenRow[this.screenRowsByLineId[lineId]] - delete this.screenRowsByLineId[lineId] - delete this.oldTileState.lines[lineId] - } - - updateLineNodes () { - for (const id of Object.keys(this.oldTileState.lines)) { - if (!this.newTileState.lines.hasOwnProperty(id)) { - this.removeLineNode(id) - } - } - - const newLineIds = [] - const newLineNodes = [] - for (const id of Object.keys(this.newTileState.lines)) { - const lineState = this.newTileState.lines[id] - if (this.oldTileState.lines.hasOwnProperty(id)) { - this.updateLineNode(id) - } else { - newLineIds.push(id) - newLineNodes.push(this.buildLineNode(id)) - this.screenRowsByLineId[id] = lineState.screenRow - this.lineIdsByScreenRow[lineState.screenRow] = id - this.oldTileState.lines[id] = Object.assign({}, lineState) - // Avoid assigning state for block decorations, because we need to - // process it later when updating the DOM. - this.oldTileState.lines[id].precedingBlockDecorations = {} - this.oldTileState.lines[id].followingBlockDecorations = {} - } - } - - while (newLineIds.length > 0) { - const id = newLineIds.shift() - const lineNode = newLineNodes.shift() - this.lineNodesByLineId[id] = lineNode - const nextNode = this.findNodeNextTo(lineNode) - if (nextNode == null) { - this.domNode.appendChild(lineNode) - } else { - this.domNode.insertBefore(lineNode, nextNode) - } - } - } - - findNodeNextTo (node) { - let i = 1 // skip highlights node - while (i < this.domNode.children.length) { - const nextNode = this.domNode.children[i] - if (this.screenRowForNode(node) < this.screenRowForNode(nextNode)) { - return nextNode - } - i++ - } - return null - } - - screenRowForNode (node) { - return parseInt(node.dataset.screenRow) - } - - buildLineNode (id) { - const {lineText, tagCodes, screenRow, decorationClasses} = this.newTileState.lines[id] - - const lineNode = this.domElementPool.buildElement('div', 'line') - lineNode.dataset.screenRow = screenRow - if (decorationClasses != null) { - for (const decorationClass of decorationClasses) { - lineNode.classList.add(decorationClass) - } - } - - const textNodes = [] - let startIndex = 0 - let openScopeNode = lineNode - for (const tagCode of tagCodes) { - if (tagCode !== 0) { - if (this.presenter.isCloseTagCode(tagCode)) { - openScopeNode = openScopeNode.parentElement - } else if (this.presenter.isOpenTagCode(tagCode)) { - const scope = this.presenter.tagForCode(tagCode) - const newScopeNode = this.domElementPool.buildElement('span', scope.replace(/\.+/g, ' ')) - openScopeNode.appendChild(newScopeNode) - openScopeNode = newScopeNode - } else { - const textNode = this.domElementPool.buildText(lineText.substr(startIndex, tagCode)) - startIndex += tagCode - openScopeNode.appendChild(textNode) - textNodes.push(textNode) - } - } - } - - if (startIndex === 0) { - const textNode = this.domElementPool.buildText(' ') - lineNode.appendChild(textNode) - textNodes.push(textNode) - } - - if (lineText.endsWith(this.presenter.displayLayer.foldCharacter)) { - // Insert a zero-width non-breaking whitespace, so that LinesYardstick can - // take the fold-marker::after pseudo-element into account during - // measurements when such marker is the last character on the line. - const textNode = this.domElementPool.buildText(ZERO_WIDTH_NBSP) - lineNode.appendChild(textNode) - textNodes.push(textNode) - } - - this.textNodesByLineId[id] = textNodes - return lineNode - } - - updateLineNode (id) { - const oldLineState = this.oldTileState.lines[id] - const newLineState = this.newTileState.lines[id] - const lineNode = this.lineNodesByLineId[id] - const newDecorationClasses = newLineState.decorationClasses - const oldDecorationClasses = oldLineState.decorationClasses - - if (oldDecorationClasses != null) { - for (const decorationClass of oldDecorationClasses) { - if (newDecorationClasses == null || !newDecorationClasses.includes(decorationClass)) { - lineNode.classList.remove(decorationClass) - } - } - } - - if (newDecorationClasses != null) { - for (const decorationClass of newDecorationClasses) { - if (oldDecorationClasses == null || !oldDecorationClasses.includes(decorationClass)) { - lineNode.classList.add(decorationClass) - } - } - } - - oldLineState.decorationClasses = newLineState.decorationClasses - - if (newLineState.screenRow !== oldLineState.screenRow) { - lineNode.dataset.screenRow = newLineState.screenRow - this.lineIdsByScreenRow[newLineState.screenRow] = id - this.screenRowsByLineId[id] = newLineState.screenRow - } - - oldLineState.screenRow = newLineState.screenRow - } - - removeDeletedBlockDecorations () { - for (const lineId of Object.keys(this.newTileState.lines)) { - const oldLineState = this.oldTileState.lines[lineId] - const newLineState = this.newTileState.lines[lineId] - for (const decorationId of Object.keys(oldLineState.precedingBlockDecorations)) { - if (!newLineState.precedingBlockDecorations.hasOwnProperty(decorationId)) { - const {topRulerNode, blockDecorationNode, bottomRulerNode} = - this.blockDecorationNodesByLineIdAndDecorationId[lineId][decorationId] - topRulerNode.remove() - blockDecorationNode.remove() - bottomRulerNode.remove() - delete this.blockDecorationNodesByLineIdAndDecorationId[lineId][decorationId] - delete oldLineState.precedingBlockDecorations[decorationId] - } - } - for (const decorationId of Object.keys(oldLineState.followingBlockDecorations)) { - if (!newLineState.followingBlockDecorations.hasOwnProperty(decorationId)) { - const {topRulerNode, blockDecorationNode, bottomRulerNode} = - this.blockDecorationNodesByLineIdAndDecorationId[lineId][decorationId] - topRulerNode.remove() - blockDecorationNode.remove() - bottomRulerNode.remove() - delete this.blockDecorationNodesByLineIdAndDecorationId[lineId][decorationId] - delete oldLineState.followingBlockDecorations[decorationId] - } - } - } - } - - updateBlockDecorations () { - for (const lineId of Object.keys(this.newTileState.lines)) { - const oldLineState = this.oldTileState.lines[lineId] - const newLineState = this.newTileState.lines[lineId] - const lineNode = this.lineNodesByLineId[lineId] - if (!this.blockDecorationNodesByLineIdAndDecorationId.hasOwnProperty(lineId)) { - this.blockDecorationNodesByLineIdAndDecorationId[lineId] = {} - } - for (const decorationId of Object.keys(newLineState.precedingBlockDecorations)) { - const oldBlockDecorationState = oldLineState.precedingBlockDecorations[decorationId] - const newBlockDecorationState = newLineState.precedingBlockDecorations[decorationId] - if (oldBlockDecorationState != null) { - const {topRulerNode, blockDecorationNode, bottomRulerNode} = - this.blockDecorationNodesByLineIdAndDecorationId[lineId][decorationId] - if (oldBlockDecorationState.screenRow !== newBlockDecorationState.screenRow) { - topRulerNode.remove() - blockDecorationNode.remove() - bottomRulerNode.remove() - topRulerNode.dataset.screenRow = newBlockDecorationState.screenRow - this.domNode.insertBefore(topRulerNode, lineNode) - blockDecorationNode.dataset.screenRow = newBlockDecorationState.screenRow - this.domNode.insertBefore(blockDecorationNode, lineNode) - bottomRulerNode.dataset.screenRow = newBlockDecorationState.screenRow - this.domNode.insertBefore(bottomRulerNode, lineNode) - } - } else { - const topRulerNode = document.createElement('div') - topRulerNode.dataset.screenRow = newBlockDecorationState.screenRow - this.domNode.insertBefore(topRulerNode, lineNode) - const blockDecorationNode = this.views.getView(newBlockDecorationState.decoration.getProperties().item) - blockDecorationNode.dataset.screenRow = newBlockDecorationState.screenRow - this.domNode.insertBefore(blockDecorationNode, lineNode) - const bottomRulerNode = document.createElement('div') - bottomRulerNode.dataset.screenRow = newBlockDecorationState.screenRow - this.domNode.insertBefore(bottomRulerNode, lineNode) - - this.blockDecorationNodesByLineIdAndDecorationId[lineId][decorationId] = - {topRulerNode, blockDecorationNode, bottomRulerNode} - } - oldLineState.precedingBlockDecorations[decorationId] = Object.assign({}, newBlockDecorationState) - } - for (const decorationId of Object.keys(newLineState.followingBlockDecorations)) { - const oldBlockDecorationState = oldLineState.followingBlockDecorations[decorationId] - const newBlockDecorationState = newLineState.followingBlockDecorations[decorationId] - if (oldBlockDecorationState != null) { - const {topRulerNode, blockDecorationNode, bottomRulerNode} = - this.blockDecorationNodesByLineIdAndDecorationId[lineId][decorationId] - if (oldBlockDecorationState.screenRow !== newBlockDecorationState.screenRow) { - topRulerNode.remove() - blockDecorationNode.remove() - bottomRulerNode.remove() - bottomRulerNode.dataset.screenRow = newBlockDecorationState.screenRow - this.domNode.insertBefore(bottomRulerNode, lineNode.nextSibling) - blockDecorationNode.dataset.screenRow = newBlockDecorationState.screenRow - this.domNode.insertBefore(blockDecorationNode, lineNode.nextSibling) - topRulerNode.dataset.screenRow = newBlockDecorationState.screenRow - this.domNode.insertBefore(topRulerNode, lineNode.nextSibling) - } - } else { - const bottomRulerNode = document.createElement('div') - bottomRulerNode.dataset.screenRow = newBlockDecorationState.screenRow - this.domNode.insertBefore(bottomRulerNode, lineNode.nextSibling) - const blockDecorationNode = this.views.getView(newBlockDecorationState.decoration.getProperties().item) - blockDecorationNode.dataset.screenRow = newBlockDecorationState.screenRow - this.domNode.insertBefore(blockDecorationNode, lineNode.nextSibling) - const topRulerNode = document.createElement('div') - topRulerNode.dataset.screenRow = newBlockDecorationState.screenRow - this.domNode.insertBefore(topRulerNode, lineNode.nextSibling) - - this.blockDecorationNodesByLineIdAndDecorationId[lineId][decorationId] = - {topRulerNode, blockDecorationNode, bottomRulerNode} - } - oldLineState.followingBlockDecorations[decorationId] = Object.assign({}, newBlockDecorationState) - } - } - } - - measureBlockDecorations () { - for (const lineId of Object.keys(this.newTileState.lines)) { - const newLineState = this.newTileState.lines[lineId] - - for (const decorationId of Object.keys(newLineState.precedingBlockDecorations)) { - const {topRulerNode, blockDecorationNode, bottomRulerNode} = - this.blockDecorationNodesByLineIdAndDecorationId[lineId][decorationId] - const width = blockDecorationNode.offsetWidth - const height = bottomRulerNode.offsetTop - topRulerNode.offsetTop - const {decoration} = newLineState.precedingBlockDecorations[decorationId] - this.presenter.setBlockDecorationDimensions(decoration, width, height) - } - for (const decorationId of Object.keys(newLineState.followingBlockDecorations)) { - const {topRulerNode, blockDecorationNode, bottomRulerNode} = - this.blockDecorationNodesByLineIdAndDecorationId[lineId][decorationId] - const width = blockDecorationNode.offsetWidth - const height = bottomRulerNode.offsetTop - topRulerNode.offsetTop - const {decoration} = newLineState.followingBlockDecorations[decorationId] - this.presenter.setBlockDecorationDimensions(decoration, width, height) - } - } - } - - lineNodeForScreenRow (screenRow) { - return this.lineNodesByLineId[this.lineIdsByScreenRow[screenRow]] - } - - lineNodeForLineId (lineId) { - return this.lineNodesByLineId[lineId] - } - - textNodesForLineId (lineId) { - return this.textNodesByLineId[lineId].slice() - } - - lineIdForScreenRow (screenRow) { - return this.lineIdsByScreenRow[screenRow] - } - - textNodesForScreenRow (screenRow) { - const textNodes = this.textNodesByLineId[this.lineIdsByScreenRow[screenRow]] - if (textNodes == null) { - return null - } else { - return textNodes.slice() - } - } -} diff --git a/src/lines-yardstick.coffee b/src/lines-yardstick.coffee deleted file mode 100644 index 308cc5af07d..00000000000 --- a/src/lines-yardstick.coffee +++ /dev/null @@ -1,133 +0,0 @@ -{Point} = require 'text-buffer' -{isPairedCharacter} = require './text-utils' - -module.exports = -class LinesYardstick - constructor: (@model, @lineNodesProvider, @lineTopIndex) -> - @rangeForMeasurement = document.createRange() - @invalidateCache() - - invalidateCache: -> - @leftPixelPositionCache = {} - - measuredRowForPixelPosition: (pixelPosition) -> - targetTop = pixelPosition.top - row = Math.floor(targetTop / @model.getLineHeightInPixels()) - row if 0 <= row - - screenPositionForPixelPosition: (pixelPosition) -> - targetTop = pixelPosition.top - row = Math.max(0, @lineTopIndex.rowForPixelPosition(targetTop)) - lineNode = @lineNodesProvider.lineNodeForScreenRow(row) - unless lineNode - lastScreenRow = @model.getLastScreenRow() - if row > lastScreenRow - return Point(lastScreenRow, @model.lineLengthForScreenRow(lastScreenRow)) - else - return Point(row, 0) - - targetLeft = pixelPosition.left - targetLeft = 0 if targetTop < 0 or targetLeft < 0 - - textNodes = @lineNodesProvider.textNodesForScreenRow(row) - lineOffset = lineNode.getBoundingClientRect().left - targetLeft += lineOffset - - textNodeIndex = 0 - low = 0 - high = textNodes.length - 1 - while low <= high - mid = low + (high - low >> 1) - textNode = textNodes[mid] - rangeRect = @clientRectForRange(textNode, 0, textNode.length) - if targetLeft < rangeRect.left - high = mid - 1 - textNodeIndex = Math.max(0, mid - 1) - else if targetLeft > rangeRect.right - low = mid + 1 - textNodeIndex = Math.min(textNodes.length - 1, mid + 1) - else - textNodeIndex = mid - break - - textNode = textNodes[textNodeIndex] - characterIndex = 0 - low = 0 - high = textNode.textContent.length - 1 - while low <= high - charIndex = low + (high - low >> 1) - if isPairedCharacter(textNode.textContent, charIndex) - nextCharIndex = charIndex + 2 - else - nextCharIndex = charIndex + 1 - - rangeRect = @clientRectForRange(textNode, charIndex, nextCharIndex) - if targetLeft < rangeRect.left - high = charIndex - 1 - characterIndex = Math.max(0, charIndex - 1) - else if targetLeft > rangeRect.right - low = nextCharIndex - characterIndex = Math.min(textNode.textContent.length, nextCharIndex) - else - if targetLeft <= ((rangeRect.left + rangeRect.right) / 2) - characterIndex = charIndex - else - characterIndex = nextCharIndex - break - - textNodeStartColumn = 0 - textNodeStartColumn += textNodes[i].length for i in [0...textNodeIndex] by 1 - Point(row, textNodeStartColumn + characterIndex) - - pixelPositionForScreenPosition: (screenPosition) -> - targetRow = screenPosition.row - targetColumn = screenPosition.column - - top = @lineTopIndex.pixelPositionAfterBlocksForRow(targetRow) - left = @leftPixelPositionForScreenPosition(targetRow, targetColumn) - - {top, left} - - leftPixelPositionForScreenPosition: (row, column) -> - lineNode = @lineNodesProvider.lineNodeForScreenRow(row) - lineId = @lineNodesProvider.lineIdForScreenRow(row) - - if lineNode? - if @leftPixelPositionCache[lineId]?[column]? - @leftPixelPositionCache[lineId][column] - else - textNodes = @lineNodesProvider.textNodesForScreenRow(row) - textNodeStartColumn = 0 - for textNode in textNodes - textNodeEndColumn = textNodeStartColumn + textNode.textContent.length - if textNodeEndColumn > column - indexInTextNode = column - textNodeStartColumn - break - else - textNodeStartColumn = textNodeEndColumn - - if textNode? - indexInTextNode ?= textNode.textContent.length - lineOffset = lineNode.getBoundingClientRect().left - if indexInTextNode is 0 - leftPixelPosition = @clientRectForRange(textNode, 0, 1).left - else - leftPixelPosition = @clientRectForRange(textNode, 0, indexInTextNode).right - leftPixelPosition -= lineOffset - - @leftPixelPositionCache[lineId] ?= {} - @leftPixelPositionCache[lineId][column] = leftPixelPosition - leftPixelPosition - else - 0 - else - 0 - - clientRectForRange: (textNode, startIndex, endIndex) -> - @rangeForMeasurement.setStart(textNode, startIndex) - @rangeForMeasurement.setEnd(textNode, endIndex) - clientRects = @rangeForMeasurement.getClientRects() - if clientRects.length is 1 - clientRects[0] - else - @rangeForMeasurement.getBoundingClientRect() diff --git a/src/marker-observation-window.coffee b/src/marker-observation-window.coffee deleted file mode 100644 index ffb92c0ab7a..00000000000 --- a/src/marker-observation-window.coffee +++ /dev/null @@ -1,12 +0,0 @@ -module.exports = -class MarkerObservationWindow - constructor: (@decorationManager, @bufferWindow) -> - - setScreenRange: (range) -> - @bufferWindow.setRange(@decorationManager.bufferRangeForScreenRange(range)) - - setBufferRange: (range) -> - @bufferWindow.setRange(range) - - destroy: -> - @bufferWindow.destroy() diff --git a/src/off-screen-block-decorations-component.js b/src/off-screen-block-decorations-component.js deleted file mode 100644 index 0460c854e04..00000000000 --- a/src/off-screen-block-decorations-component.js +++ /dev/null @@ -1,62 +0,0 @@ -module.exports = class OffScreenBlockDecorationsComponent { - constructor ({presenter, views}) { - this.presenter = presenter - this.views = views - this.newState = {offScreenBlockDecorations: {}, width: 0} - this.oldState = {offScreenBlockDecorations: {}, width: 0} - this.domNode = document.createElement('div') - this.domNode.style.visibility = 'hidden' - this.domNode.style.position = 'absolute' - this.blockDecorationNodesById = {} - } - - getDomNode () { - return this.domNode - } - - updateSync (state) { - this.newState = state.content - - if (this.newState.width !== this.oldState.width) { - this.domNode.style.width = `${this.newState.width}px` - this.oldState.width = this.newState.width - } - - for (const id of Object.keys(this.oldState.offScreenBlockDecorations)) { - if (!this.newState.offScreenBlockDecorations.hasOwnProperty(id)) { - const {topRuler, blockDecoration, bottomRuler} = this.blockDecorationNodesById[id] - topRuler.remove() - blockDecoration.remove() - bottomRuler.remove() - delete this.blockDecorationNodesById[id] - delete this.oldState.offScreenBlockDecorations[id] - } - } - - for (const id of Object.keys(this.newState.offScreenBlockDecorations)) { - const decoration = this.newState.offScreenBlockDecorations[id] - if (!this.oldState.offScreenBlockDecorations.hasOwnProperty(id)) { - const topRuler = document.createElement('div') - this.domNode.appendChild(topRuler) - const blockDecoration = this.views.getView(decoration.getProperties().item) - this.domNode.appendChild(blockDecoration) - const bottomRuler = document.createElement('div') - this.domNode.appendChild(bottomRuler) - - this.blockDecorationNodesById[id] = {topRuler, blockDecoration, bottomRuler} - } - - this.oldState.offScreenBlockDecorations[id] = decoration - } - } - - measureBlockDecorations () { - for (const id of Object.keys(this.blockDecorationNodesById)) { - const {topRuler, blockDecoration, bottomRuler} = this.blockDecorationNodesById[id] - const width = blockDecoration.offsetWidth - const height = bottomRuler.offsetTop - topRuler.offsetTop - const decoration = this.newState.offScreenBlockDecorations[id] - this.presenter.setBlockDecorationDimensions(decoration, width, height) - } - } -} diff --git a/src/scrollbar-component.coffee b/src/scrollbar-component.coffee deleted file mode 100644 index 9d418e8c6cd..00000000000 --- a/src/scrollbar-component.coffee +++ /dev/null @@ -1,79 +0,0 @@ -module.exports = -class ScrollbarComponent - constructor: ({@orientation, @onScroll}) -> - @domNode = document.createElement('div') - @domNode.classList.add "#{@orientation}-scrollbar" - @domNode.style['-webkit-transform'] = 'translateZ(0)' # See atom/atom#3559 - @domNode.style.left = 0 if @orientation is 'horizontal' - - @contentNode = document.createElement('div') - @contentNode.classList.add "scrollbar-content" - @domNode.appendChild(@contentNode) - - @domNode.addEventListener 'scroll', @onScrollCallback - - destroy: -> - @domNode.removeEventListener 'scroll', @onScrollCallback - @onScroll = null - - getDomNode: -> - @domNode - - updateSync: (state) -> - @oldState ?= {} - switch @orientation - when 'vertical' - @newState = state.verticalScrollbar - @updateVertical() - when 'horizontal' - @newState = state.horizontalScrollbar - @updateHorizontal() - - if @newState.visible isnt @oldState.visible - if @newState.visible - @domNode.style.display = '' - else - @domNode.style.display = 'none' - @oldState.visible = @newState.visible - - updateVertical: -> - if @newState.width isnt @oldState.width - @domNode.style.width = @newState.width + 'px' - @oldState.width = @newState.width - - if @newState.bottom isnt @oldState.bottom - @domNode.style.bottom = @newState.bottom + 'px' - @oldState.bottom = @newState.bottom - - if @newState.scrollHeight isnt @oldState.scrollHeight - @contentNode.style.height = @newState.scrollHeight + 'px' - @oldState.scrollHeight = @newState.scrollHeight - - if @newState.scrollTop isnt @oldState.scrollTop - @domNode.scrollTop = @newState.scrollTop - @oldState.scrollTop = @newState.scrollTop - - updateHorizontal: -> - if @newState.height isnt @oldState.height - @domNode.style.height = @newState.height + 'px' - @oldState.height = @newState.height - - if @newState.right isnt @oldState.right - @domNode.style.right = @newState.right + 'px' - @oldState.right = @newState.right - - if @newState.scrollWidth isnt @oldState.scrollWidth - @contentNode.style.width = @newState.scrollWidth + 'px' - @oldState.scrollWidth = @newState.scrollWidth - - if @newState.scrollLeft isnt @oldState.scrollLeft - @domNode.scrollLeft = @newState.scrollLeft - @oldState.scrollLeft = @newState.scrollLeft - - - onScrollCallback: => - switch @orientation - when 'vertical' - @onScroll(@domNode.scrollTop) - when 'horizontal' - @onScroll(@domNode.scrollLeft) diff --git a/src/scrollbar-corner-component.coffee b/src/scrollbar-corner-component.coffee deleted file mode 100644 index bc059f12cd7..00000000000 --- a/src/scrollbar-corner-component.coffee +++ /dev/null @@ -1,38 +0,0 @@ -module.exports = -class ScrollbarCornerComponent - constructor: -> - @domNode = document.createElement('div') - @domNode.classList.add('scrollbar-corner') - - @contentNode = document.createElement('div') - @domNode.appendChild(@contentNode) - - getDomNode: -> - @domNode - - updateSync: (state) -> - @oldState ?= {} - @newState ?= {} - - newHorizontalState = state.horizontalScrollbar - newVerticalState = state.verticalScrollbar - @newState.visible = newHorizontalState.visible and newVerticalState.visible - @newState.height = newHorizontalState.height - @newState.width = newVerticalState.width - - if @newState.visible isnt @oldState.visible - if @newState.visible - @domNode.style.display = '' - else - @domNode.style.display = 'none' - @oldState.visible = @newState.visible - - if @newState.height isnt @oldState.height - @domNode.style.height = @newState.height + 'px' - @contentNode.style.height = @newState.height + 1 + 'px' - @oldState.height = @newState.height - - if @newState.width isnt @oldState.width - @domNode.style.width = @newState.width + 'px' - @contentNode.style.width = @newState.width + 1 + 'px' - @oldState.width = @newState.width diff --git a/src/text-editor-component-old.coffee b/src/text-editor-component-old.coffee deleted file mode 100644 index e38030b91d4..00000000000 --- a/src/text-editor-component-old.coffee +++ /dev/null @@ -1,967 +0,0 @@ -scrollbarStyle = require 'scrollbar-style' -{Range, Point} = require 'text-buffer' -{CompositeDisposable, Disposable} = require 'event-kit' -{ipcRenderer} = require 'electron' -Grim = require 'grim' -ElementResizeDetector = require('element-resize-detector') -elementResizeDetector = null - -TextEditorPresenter = require './text-editor-presenter' -GutterContainerComponent = require './gutter-container-component' -InputComponent = require './input-component' -LinesComponent = require './lines-component' -OffScreenBlockDecorationsComponent = require './off-screen-block-decorations-component' -ScrollbarComponent = require './scrollbar-component' -ScrollbarCornerComponent = require './scrollbar-corner-component' -OverlayManager = require './overlay-manager' -DOMElementPool = require './dom-element-pool' -LinesYardstick = require './lines-yardstick' -LineTopIndex = require 'line-top-index' - -module.exports = -class TextEditorComponent - cursorBlinkPeriod: 800 - cursorBlinkResumeDelay: 100 - tileSize: 12 - - pendingScrollTop: null - pendingScrollLeft: null - updateRequested: false - updatesPaused: false - updateRequestedWhilePaused: false - heightAndWidthMeasurementRequested: false - inputEnabled: true - measureScrollbarsWhenShown: true - measureLineHeightAndDefaultCharWidthWhenShown: true - stylingChangeAnimationFrameRequested: false - gutterComponent: null - mounted: true - initialized: false - - Object.defineProperty @prototype, "domNode", - get: -> @domNodeValue - set: (domNode) -> - @assert domNode?, "TextEditorComponent::domNode was set to null." - @domNodeValue = domNode - - constructor: ({@editor, @hostElement, tileSize, @views, @themes, @styles, @assert, hiddenInputElement}) -> - @tileSize = tileSize if tileSize? - @disposables = new CompositeDisposable - - lineTopIndex = new LineTopIndex({ - defaultLineHeight: @editor.getLineHeightInPixels() - }) - @presenter = new TextEditorPresenter - model: @editor - tileSize: tileSize - cursorBlinkPeriod: @cursorBlinkPeriod - cursorBlinkResumeDelay: @cursorBlinkResumeDelay - stoppedScrollingDelay: 200 - lineTopIndex: lineTopIndex - autoHeight: @editor.getAutoHeight() - - @presenter.onDidUpdateState(@requestUpdate) - - @domElementPool = new DOMElementPool - @domNode = document.createElement('div') - @domNode.classList.add('editor-contents--private') - - @overlayManager = new OverlayManager(@presenter, @domNode, @views) - - @scrollViewNode = document.createElement('div') - @scrollViewNode.classList.add('scroll-view') - @domNode.appendChild(@scrollViewNode) - - @hiddenInputComponent = new InputComponent(hiddenInputElement) - @scrollViewNode.appendChild(hiddenInputElement) - # Add a getModel method to the hidden input component to make it easy to - # access the editor in response to DOM events or when using - # document.activeElement. - hiddenInputElement.getModel = => @editor - - @linesComponent = new LinesComponent({@presenter, @domElementPool, @assert, @grammars, @views}) - @scrollViewNode.appendChild(@linesComponent.getDomNode()) - - @offScreenBlockDecorationsComponent = new OffScreenBlockDecorationsComponent({@presenter, @views}) - @scrollViewNode.appendChild(@offScreenBlockDecorationsComponent.getDomNode()) - - @linesYardstick = new LinesYardstick(@editor, @linesComponent, lineTopIndex) - @presenter.setLinesYardstick(@linesYardstick) - - @horizontalScrollbarComponent = new ScrollbarComponent({orientation: 'horizontal', onScroll: @onHorizontalScroll}) - @scrollViewNode.appendChild(@horizontalScrollbarComponent.getDomNode()) - - @verticalScrollbarComponent = new ScrollbarComponent({orientation: 'vertical', onScroll: @onVerticalScroll}) - @domNode.appendChild(@verticalScrollbarComponent.getDomNode()) - - @scrollbarCornerComponent = new ScrollbarCornerComponent - @domNode.appendChild(@scrollbarCornerComponent.getDomNode()) - - @observeEditor() - @listenForDOMEvents() - - @disposables.add @styles.onDidAddStyleElement @onStylesheetsChanged - @disposables.add @styles.onDidUpdateStyleElement @onStylesheetsChanged - @disposables.add @styles.onDidRemoveStyleElement @onStylesheetsChanged - unless @themes.isInitialLoadComplete() - @disposables.add @themes.onDidChangeActiveThemes @onAllThemesLoaded - @disposables.add scrollbarStyle.onDidChangePreferredScrollbarStyle @refreshScrollbars - - @updateSync() - @initialized = true - - destroy: -> - @mounted = false - @disposables.dispose() - @presenter.destroy() - @gutterContainerComponent?.destroy() - @domElementPool.clear() - - @verticalScrollbarComponent.destroy() - @horizontalScrollbarComponent.destroy() - - @onVerticalScroll = null - @onHorizontalScroll = null - - @intersectionObserver?.disconnect() - - didAttach: -> - @intersectionObserver = new IntersectionObserver((entries) => - {intersectionRect} = entries[entries.length - 1] - if intersectionRect.width > 0 or intersectionRect.height > 0 - @becameVisible() - ) - @intersectionObserver.observe(@domNode) - @becameVisible() if @isVisible() - - measureDimensions = @measureDimensions.bind(this) - elementResizeDetector ?= ElementResizeDetector({strategy: 'scroll'}) - elementResizeDetector.listenTo(@domNode, measureDimensions) - @disposables.add(new Disposable => elementResizeDetector.removeListener(@domNode, measureDimensions)) - - measureWindowSize = @measureWindowSize.bind(this) - window.addEventListener('resize', measureWindowSize) - @disposables.add(new Disposable -> window.removeEventListener('resize', measureWindowSize)) - - getDomNode: -> - @domNode - - updateSync: -> - @updateSyncPreMeasurement() - - @oldState ?= {width: null} - @newState = @presenter.getPostMeasurementState() - - if @editor.getLastSelection()? and not @editor.getLastSelection().isEmpty() - @domNode.classList.add('has-selection') - else - @domNode.classList.remove('has-selection') - - if @newState.focused isnt @oldState.focused - @domNode.classList.toggle('is-focused', @newState.focused) - - @performedInitialMeasurement = false if @editor.isDestroyed() - - if @performedInitialMeasurement - if @newState.height isnt @oldState.height - if @newState.height? - @domNode.style.height = @newState.height + 'px' - else - @domNode.style.height = '' - - if @newState.width isnt @oldState.width - if @newState.width? - @hostElement.style.width = @newState.width + 'px' - else - @hostElement.style.width = '' - @oldState.width = @newState.width - - if @newState.gutters.length - @mountGutterContainerComponent() unless @gutterContainerComponent? - @gutterContainerComponent.updateSync(@newState) - else - @gutterContainerComponent?.getDomNode()?.remove() - @gutterContainerComponent = null - - @hiddenInputComponent.updateSync(@newState) - @offScreenBlockDecorationsComponent.updateSync(@newState) - @linesComponent.updateSync(@newState) - @horizontalScrollbarComponent.updateSync(@newState) - @verticalScrollbarComponent.updateSync(@newState) - @scrollbarCornerComponent.updateSync(@newState) - - @overlayManager?.render(@newState) - - if @clearPoolAfterUpdate - @domElementPool.clear() - @clearPoolAfterUpdate = false - - if @editor.isAlive() - @updateParentViewFocusedClassIfNeeded() - @updateParentViewMiniClass() - - updateSyncPreMeasurement: -> - @linesComponent.updateSync(@presenter.getPreMeasurementState()) - - readAfterUpdateSync: => - @linesComponent.measureBlockDecorations() - @offScreenBlockDecorationsComponent.measureBlockDecorations() - - mountGutterContainerComponent: -> - @gutterContainerComponent = new GutterContainerComponent({@editor, @onLineNumberGutterMouseDown, @domElementPool, @views}) - @domNode.insertBefore(@gutterContainerComponent.getDomNode(), @domNode.firstChild) - - becameVisible: -> - @updatesPaused = true - # Always invalidate LinesYardstick measurements when the editor becomes - # visible again, because content might have been reflowed and measurements - # could be outdated. - @invalidateMeasurements() - @measureScrollbars() if @measureScrollbarsWhenShown - @sampleFontStyling() - @measureWindowSize() - @measureDimensions() - @measureLineHeightAndDefaultCharWidth() if @measureLineHeightAndDefaultCharWidthWhenShown - @editor.setVisible(true) - @performedInitialMeasurement = true - @updatesPaused = false - @updateSync() if @canUpdate() - - requestUpdate: => - return unless @canUpdate() - - if @updatesPaused - @updateRequestedWhilePaused = true - return - - if @hostElement.isUpdatedSynchronously() - @updateSync() - else unless @updateRequested - @updateRequested = true - @views.updateDocument => - @updateRequested = false - @updateSync() if @canUpdate() - @views.readDocument(@readAfterUpdateSync) - - canUpdate: -> - @mounted and @editor.isAlive() - - requestAnimationFrame: (fn) -> - @updatesPaused = true - requestAnimationFrame => - fn() - @updatesPaused = false - if @updateRequestedWhilePaused and @canUpdate() - @updateRequestedWhilePaused = false - @requestUpdate() - - getTopmostDOMNode: -> - @hostElement - - observeEditor: -> - @disposables.add @editor.observeGrammar(@onGrammarChanged) - - listenForDOMEvents: -> - @domNode.addEventListener 'mousewheel', @onMouseWheel - @domNode.addEventListener 'textInput', @onTextInput - @scrollViewNode.addEventListener 'mousedown', @onMouseDown - @scrollViewNode.addEventListener 'scroll', @onScrollViewScroll - - @detectAccentedCharacterMenu() - @listenForIMEEvents() - @trackSelectionClipboard() if process.platform is 'linux' - - detectAccentedCharacterMenu: -> - # We need to get clever to detect when the accented character menu is - # opened on macOS. Usually, every keydown event that could cause input is - # followed by a corresponding keypress. However, pressing and holding - # long enough to open the accented character menu causes additional keydown - # events to fire that aren't followed by their own keypress and textInput - # events. - # - # Therefore, we assume the accented character menu has been deployed if, - # before observing any keyup event, we observe events in the following - # sequence: - # - # keydown(keyCode: X), keypress, keydown(keyCode: X) - # - # The keyCode X must be the same in the keydown events that bracket the - # keypress, meaning we're *holding* the _same_ key we intially pressed. - # Got that? - lastKeydown = null - lastKeydownBeforeKeypress = null - - @domNode.addEventListener 'keydown', (event) => - if lastKeydownBeforeKeypress - if lastKeydownBeforeKeypress.keyCode is event.keyCode - @openedAccentedCharacterMenu = true - lastKeydownBeforeKeypress = null - else - lastKeydown = event - - @domNode.addEventListener 'keypress', => - lastKeydownBeforeKeypress = lastKeydown - lastKeydown = null - - # This cancels the accented character behavior if we type a key normally - # with the menu open. - @openedAccentedCharacterMenu = false - - @domNode.addEventListener 'keyup', -> - lastKeydownBeforeKeypress = null - lastKeydown = null - - listenForIMEEvents: -> - # The IME composition events work like this: - # - # User types 's', chromium pops up the completion helper - # 1. compositionstart fired - # 2. compositionupdate fired; event.data == 's' - # User hits arrow keys to move around in completion helper - # 3. compositionupdate fired; event.data == 's' for each arry key press - # User escape to cancel - # 4. compositionend fired - # OR User chooses a completion - # 4. compositionend fired - # 5. textInput fired; event.data == the completion string - - checkpoint = null - @domNode.addEventListener 'compositionstart', => - if @openedAccentedCharacterMenu - @editor.selectLeft() - @openedAccentedCharacterMenu = false - checkpoint = @editor.createCheckpoint() - @domNode.addEventListener 'compositionupdate', (event) => - @editor.insertText(event.data, select: true) - @domNode.addEventListener 'compositionend', (event) => - @editor.revertToCheckpoint(checkpoint) - event.target.value = '' - - # Listen for selection changes and store the currently selected text - # in the selection clipboard. This is only applicable on Linux. - trackSelectionClipboard: -> - timeoutId = null - writeSelectedTextToSelectionClipboard = => - return if @editor.isDestroyed() - if selectedText = @editor.getSelectedText() - # This uses ipcRenderer.send instead of clipboard.writeText because - # clipboard.writeText is a sync ipcRenderer call on Linux and that - # will slow down selections. - ipcRenderer.send('write-text-to-selection-clipboard', selectedText) - @disposables.add @editor.onDidChangeSelectionRange -> - clearTimeout(timeoutId) - timeoutId = setTimeout(writeSelectedTextToSelectionClipboard) - - onGrammarChanged: => - if @scopedConfigDisposables? - @scopedConfigDisposables.dispose() - @disposables.remove(@scopedConfigDisposables) - - @scopedConfigDisposables = new CompositeDisposable - @disposables.add(@scopedConfigDisposables) - - focused: -> - if @mounted - @presenter.setFocused(true) - - blurred: -> - if @mounted - @presenter.setFocused(false) - - onTextInput: (event) => - event.stopPropagation() - - # WARNING: If we call preventDefault on the input of a space character, - # then the browser interprets the spacebar keypress as a page-down command, - # causing spaces to scroll elements containing editors. This is impossible - # to test. - event.preventDefault() if event.data isnt ' ' - - return unless @isInputEnabled() - - # Workaround of the accented character suggestion feature in macOS. - # This will only occur when the user is not composing in IME mode. - # When the user selects a modified character from the macOS menu, `textInput` - # will occur twice, once for the initial character, and once for the - # modified character. However, only a single keypress will have fired. If - # this is the case, select backward to replace the original character. - if @openedAccentedCharacterMenu - @editor.selectLeft() - @openedAccentedCharacterMenu = false - - @editor.insertText(event.data, groupUndo: true) - - onVerticalScroll: (scrollTop) => - return if @updateRequested or scrollTop is @presenter.getScrollTop() - - animationFramePending = @pendingScrollTop? - @pendingScrollTop = scrollTop - unless animationFramePending - @requestAnimationFrame => - pendingScrollTop = @pendingScrollTop - @pendingScrollTop = null - @presenter.setScrollTop(pendingScrollTop) - @presenter.commitPendingScrollTopPosition() - - onHorizontalScroll: (scrollLeft) => - return if @updateRequested or scrollLeft is @presenter.getScrollLeft() - - animationFramePending = @pendingScrollLeft? - @pendingScrollLeft = scrollLeft - unless animationFramePending - @requestAnimationFrame => - @presenter.setScrollLeft(@pendingScrollLeft) - @presenter.commitPendingScrollLeftPosition() - @pendingScrollLeft = null - - onMouseWheel: (event) => - # Only scroll in one direction at a time - {wheelDeltaX, wheelDeltaY} = event - - if Math.abs(wheelDeltaX) > Math.abs(wheelDeltaY) - # Scrolling horizontally - previousScrollLeft = @presenter.getScrollLeft() - updatedScrollLeft = previousScrollLeft - Math.round(wheelDeltaX * @editor.getScrollSensitivity() / 100) - - event.preventDefault() if @presenter.canScrollLeftTo(updatedScrollLeft) - @presenter.setScrollLeft(updatedScrollLeft) - else - # Scrolling vertically - @presenter.setMouseWheelScreenRow(@screenRowForNode(event.target)) - previousScrollTop = @presenter.getScrollTop() - updatedScrollTop = previousScrollTop - Math.round(wheelDeltaY * @editor.getScrollSensitivity() / 100) - - event.preventDefault() if @presenter.canScrollTopTo(updatedScrollTop) - @presenter.setScrollTop(updatedScrollTop) - - onScrollViewScroll: => - if @mounted - @scrollViewNode.scrollTop = 0 - @scrollViewNode.scrollLeft = 0 - - onDidChangeScrollTop: (callback) -> - @presenter.onDidChangeScrollTop(callback) - - onDidChangeScrollLeft: (callback) -> - @presenter.onDidChangeScrollLeft(callback) - - setScrollLeft: (scrollLeft) -> - @presenter.setScrollLeft(scrollLeft) - - setScrollRight: (scrollRight) -> - @presenter.setScrollRight(scrollRight) - - setScrollTop: (scrollTop) -> - @presenter.setScrollTop(scrollTop) - - setScrollBottom: (scrollBottom) -> - @presenter.setScrollBottom(scrollBottom) - - getScrollTop: -> - @presenter.getScrollTop() - - getScrollLeft: -> - @presenter.getScrollLeft() - - getScrollRight: -> - @presenter.getScrollRight() - - getScrollBottom: -> - @presenter.getScrollBottom() - - getScrollHeight: -> - @presenter.getScrollHeight() - - getScrollWidth: -> - @presenter.getScrollWidth() - - getMaxScrollTop: -> - @presenter.getMaxScrollTop() - - getVerticalScrollbarWidth: -> - @presenter.getVerticalScrollbarWidth() - - getHorizontalScrollbarHeight: -> - @presenter.getHorizontalScrollbarHeight() - - getVisibleRowRange: -> - @presenter.getVisibleRowRange() - - pixelPositionForScreenPosition: (screenPosition, clip=true) -> - screenPosition = Point.fromObject(screenPosition) - screenPosition = @editor.clipScreenPosition(screenPosition) if clip - - unless @presenter.isRowRendered(screenPosition.row) - @presenter.setScreenRowsToMeasure([screenPosition.row]) - - unless @linesComponent.lineNodeForScreenRow(screenPosition.row)? - @updateSyncPreMeasurement() - - pixelPosition = @linesYardstick.pixelPositionForScreenPosition(screenPosition) - @presenter.clearScreenRowsToMeasure() - pixelPosition - - screenPositionForPixelPosition: (pixelPosition) -> - row = @linesYardstick.measuredRowForPixelPosition(pixelPosition) - if row? and not @presenter.isRowRendered(row) - @presenter.setScreenRowsToMeasure([row]) - @updateSyncPreMeasurement() - - position = @linesYardstick.screenPositionForPixelPosition(pixelPosition) - @presenter.clearScreenRowsToMeasure() - position - - pixelRectForScreenRange: (screenRange) -> - rowsToMeasure = [] - unless @presenter.isRowRendered(screenRange.start.row) - rowsToMeasure.push(screenRange.start.row) - unless @presenter.isRowRendered(screenRange.end.row) - rowsToMeasure.push(screenRange.end.row) - - if rowsToMeasure.length > 0 - @presenter.setScreenRowsToMeasure(rowsToMeasure) - @updateSyncPreMeasurement() - - rect = @presenter.absolutePixelRectForScreenRange(screenRange) - - if rowsToMeasure.length > 0 - @presenter.clearScreenRowsToMeasure() - - rect - - pixelRangeForScreenRange: (screenRange, clip=true) -> - {start, end} = Range.fromObject(screenRange) - {start: @pixelPositionForScreenPosition(start, clip), end: @pixelPositionForScreenPosition(end, clip)} - - pixelPositionForBufferPosition: (bufferPosition) -> - @pixelPositionForScreenPosition( - @editor.screenPositionForBufferPosition(bufferPosition) - ) - - invalidateBlockDecorationDimensions: -> - @presenter.invalidateBlockDecorationDimensions(arguments...) - - onMouseDown: (event) => - # Handle middle mouse button on linux platform only (paste clipboard) - if event.button is 1 and process.platform is 'linux' - if selection = require('./safe-clipboard').readText('selection') - screenPosition = @screenPositionForMouseEvent(event) - @editor.setCursorScreenPosition(screenPosition, autoscroll: false) - @editor.insertText(selection) - return - - # Handle mouse down events for left mouse button only - # (except middle mouse button on linux platform, see above) - unless event.button is 0 - return - - return if event.target?.classList.contains('horizontal-scrollbar') - - {detail, shiftKey, metaKey, ctrlKey} = event - - # CTRL+click brings up the context menu on macOS, so don't handle those either - return if ctrlKey and process.platform is 'darwin' - - # Prevent focusout event on hidden input if editor is already focused - event.preventDefault() if @oldState.focused - - screenPosition = @screenPositionForMouseEvent(event) - - if event.target?.classList.contains('fold-marker') - bufferPosition = @editor.bufferPositionForScreenPosition(screenPosition) - @editor.destroyFoldsIntersectingBufferRange([bufferPosition, bufferPosition]) - return - - switch detail - when 1 - if shiftKey - @editor.selectToScreenPosition(screenPosition) - else if metaKey or (ctrlKey and process.platform isnt 'darwin') - cursorAtScreenPosition = @editor.getCursorAtScreenPosition(screenPosition) - if cursorAtScreenPosition and @editor.hasMultipleCursors() - cursorAtScreenPosition.destroy() - else - @editor.addCursorAtScreenPosition(screenPosition, autoscroll: false) - else - @editor.setCursorScreenPosition(screenPosition, autoscroll: false) - when 2 - @editor.getLastSelection().selectWord(autoscroll: false) - when 3 - @editor.getLastSelection().selectLine(null, autoscroll: false) - - @handleDragUntilMouseUp (screenPosition) => - @editor.selectToScreenPosition(screenPosition, suppressSelectionMerge: true, autoscroll: false) - - onLineNumberGutterMouseDown: (event) => - return unless event.button is 0 # only handle the left mouse button - - {shiftKey, metaKey, ctrlKey} = event - - if shiftKey - @onGutterShiftClick(event) - else if metaKey or (ctrlKey and process.platform isnt 'darwin') - @onGutterMetaClick(event) - else - @onGutterClick(event) - - onGutterClick: (event) => - clickedScreenRow = @screenPositionForMouseEvent(event).row - clickedBufferRow = @editor.bufferRowForScreenRow(clickedScreenRow) - initialScreenRange = @editor.screenRangeForBufferRange([[clickedBufferRow, 0], [clickedBufferRow + 1, 0]]) - @editor.setSelectedScreenRange(initialScreenRange, preserveFolds: true, autoscroll: false) - @handleGutterDrag(initialScreenRange) - - onGutterMetaClick: (event) => - clickedScreenRow = @screenPositionForMouseEvent(event).row - clickedBufferRow = @editor.bufferRowForScreenRow(clickedScreenRow) - initialScreenRange = @editor.screenRangeForBufferRange([[clickedBufferRow, 0], [clickedBufferRow + 1, 0]]) - @editor.addSelectionForScreenRange(initialScreenRange, autoscroll: false) - @handleGutterDrag(initialScreenRange) - - onGutterShiftClick: (event) => - tailScreenPosition = @editor.getLastSelection().getTailScreenPosition() - clickedScreenRow = @screenPositionForMouseEvent(event).row - clickedBufferRow = @editor.bufferRowForScreenRow(clickedScreenRow) - clickedLineScreenRange = @editor.screenRangeForBufferRange([[clickedBufferRow, 0], [clickedBufferRow + 1, 0]]) - - if clickedScreenRow < tailScreenPosition.row - @editor.selectToScreenPosition(clickedLineScreenRange.start, suppressSelectionMerge: true, autoscroll: false) - else - @editor.selectToScreenPosition(clickedLineScreenRange.end, suppressSelectionMerge: true, autoscroll: false) - - @handleGutterDrag(new Range(tailScreenPosition, tailScreenPosition)) - - handleGutterDrag: (initialRange) -> - @handleDragUntilMouseUp (screenPosition) => - dragRow = screenPosition.row - if dragRow < initialRange.start.row - startPosition = @editor.clipScreenPosition([dragRow, 0], skipSoftWrapIndentation: true) - screenRange = new Range(startPosition, startPosition).union(initialRange) - @editor.getLastSelection().setScreenRange(screenRange, reversed: true, autoscroll: false, preserveFolds: true) - else - endPosition = @editor.clipScreenPosition([dragRow + 1, 0], clipDirection: 'backward') - screenRange = new Range(endPosition, endPosition).union(initialRange) - @editor.getLastSelection().setScreenRange(screenRange, reversed: false, autoscroll: false, preserveFolds: true) - - onStylesheetsChanged: (styleElement) => - return unless @performedInitialMeasurement - return unless @themes.isInitialLoadComplete() - - # Handle styling change synchronously if a global editor property such as - # font size might have changed. Otherwise coalesce multiple style sheet changes - # into a measurement on the next animation frame to prevent excessive thrashing. - if styleElement.getAttribute('source-path') is 'global-text-editor-styles' - @handleStylingChange() - else if not @stylingChangeAnimationFrameRequested - @stylingChangeAnimationFrameRequested = true - requestAnimationFrame => - @stylingChangeAnimationFrameRequested = false - if @mounted - @refreshScrollbars() if not styleElement.sheet? or @containsScrollbarSelector(styleElement.sheet) - @handleStylingChange() - - onAllThemesLoaded: => - @refreshScrollbars() - @handleStylingChange() - - handleStylingChange: => - if @isVisible() - @sampleFontStyling() - @invalidateMeasurements() - - handleDragUntilMouseUp: (dragHandler) -> - dragging = false - lastMousePosition = {} - animationLoop = => - @requestAnimationFrame => - if dragging and @mounted - linesClientRect = @linesComponent.getDomNode().getBoundingClientRect() - autoscroll(lastMousePosition, linesClientRect) - screenPosition = @screenPositionForMouseEvent(lastMousePosition, linesClientRect) - dragHandler(screenPosition) - animationLoop() - else if not @mounted - stopDragging() - - onMouseMove = (event) -> - lastMousePosition.clientX = event.clientX - lastMousePosition.clientY = event.clientY - - # Start the animation loop when the mouse moves prior to a mouseup event - unless dragging - dragging = true - animationLoop() - - # Stop dragging when cursor enters dev tools because we can't detect mouseup - onMouseUp() if event.which is 0 - - onMouseUp = (event) => - if dragging - stopDragging() - @editor.finalizeSelections() - @editor.mergeIntersectingSelections() - - stopDragging = -> - dragging = false - window.removeEventListener('mousemove', onMouseMove) - window.removeEventListener('mouseup', onMouseUp) - disposables.dispose() - - autoscroll = (mouseClientPosition) => - {top, bottom, left, right} = @scrollViewNode.getBoundingClientRect() - top += 30 - bottom -= 30 - left += 30 - right -= 30 - - if mouseClientPosition.clientY < top - mouseYDelta = top - mouseClientPosition.clientY - yDirection = -1 - else if mouseClientPosition.clientY > bottom - mouseYDelta = mouseClientPosition.clientY - bottom - yDirection = 1 - - if mouseClientPosition.clientX < left - mouseXDelta = left - mouseClientPosition.clientX - xDirection = -1 - else if mouseClientPosition.clientX > right - mouseXDelta = mouseClientPosition.clientX - right - xDirection = 1 - - if mouseYDelta? - @presenter.setScrollTop(@presenter.getScrollTop() + yDirection * scaleScrollDelta(mouseYDelta)) - @presenter.commitPendingScrollTopPosition() - - if mouseXDelta? - @presenter.setScrollLeft(@presenter.getScrollLeft() + xDirection * scaleScrollDelta(mouseXDelta)) - @presenter.commitPendingScrollLeftPosition() - - scaleScrollDelta = (scrollDelta) -> - Math.pow(scrollDelta / 2, 3) / 280 - - window.addEventListener('mousemove', onMouseMove) - window.addEventListener('mouseup', onMouseUp) - disposables = new CompositeDisposable - disposables.add(@editor.getBuffer().onWillChange(onMouseUp)) - disposables.add(@editor.onDidDestroy(stopDragging)) - - isVisible: -> - # Investigating an exception that occurs here due to ::domNode being null. - @assert @domNode?, "TextEditorComponent::domNode was null.", (error) => - error.metadata = {@initialized} - - @domNode? and (@domNode.offsetHeight > 0 or @domNode.offsetWidth > 0) - - # Measure explicitly-styled height and width and relay them to the model. If - # these values aren't explicitly styled, we assume the editor is unconstrained - # and use the scrollHeight / scrollWidth as its height and width in - # calculations. - measureDimensions: -> - # If we don't assign autoHeight explicitly, we try to automatically disable - # auto-height in certain circumstances. This is legacy behavior that we - # would rather not implement, but we can't remove it without risking - # breakage currently. - unless @editor.autoHeight? - {position, top, bottom} = getComputedStyle(@hostElement) - hasExplicitTopAndBottom = (position is 'absolute' and top isnt 'auto' and bottom isnt 'auto') - hasInlineHeight = @hostElement.style.height.length > 0 - - if hasInlineHeight or hasExplicitTopAndBottom - if @presenter.autoHeight - @presenter.setAutoHeight(false) - if hasExplicitTopAndBottom - Grim.deprecate(""" - Assigning editor #{@editor.id}'s height explicitly via `position: 'absolute'` and an assigned `top` and `bottom` implicitly assigns the `autoHeight` property to false on the editor. - This behavior is deprecated and will not be supported in the future. Please explicitly assign `autoHeight` on this editor. - """) - else if hasInlineHeight - Grim.deprecate(""" - Assigning editor #{@editor.id}'s height explicitly via an inline style implicitly assigns the `autoHeight` property to false on the editor. - This behavior is deprecated and will not be supported in the future. Please explicitly assign `autoHeight` on this editor. - """) - else - @presenter.setAutoHeight(true) - - if @presenter.autoHeight - @presenter.setExplicitHeight(null) - else if @hostElement.offsetHeight > 0 - @presenter.setExplicitHeight(@hostElement.offsetHeight) - - clientWidth = @scrollViewNode.clientWidth - paddingLeft = parseInt(getComputedStyle(@scrollViewNode).paddingLeft) - clientWidth -= paddingLeft - if clientWidth > 0 - @presenter.setContentFrameWidth(clientWidth) - - @presenter.setGutterWidth(@gutterContainerComponent?.getDomNode().offsetWidth ? 0) - @presenter.setBoundingClientRect(@hostElement.getBoundingClientRect()) - - measureWindowSize: -> - return unless @mounted - - # FIXME: on Ubuntu (via xvfb) `window.innerWidth` reports an incorrect value - # when window gets resized through `atom.setWindowDimensions({width: - # windowWidth, height: windowHeight})`. - @presenter.setWindowSize(window.innerWidth, window.innerHeight) - - sampleFontStyling: => - oldFontSize = @fontSize - oldFontFamily = @fontFamily - oldLineHeight = @lineHeight - - {@fontSize, @fontFamily, @lineHeight} = getComputedStyle(@getTopmostDOMNode()) - - if @fontSize isnt oldFontSize or @fontFamily isnt oldFontFamily or @lineHeight isnt oldLineHeight - @clearPoolAfterUpdate = true - @measureLineHeightAndDefaultCharWidth() - @invalidateMeasurements() - - measureLineHeightAndDefaultCharWidth: -> - if @isVisible() - @measureLineHeightAndDefaultCharWidthWhenShown = false - @linesComponent.measureLineHeightAndDefaultCharWidth() - else - @measureLineHeightAndDefaultCharWidthWhenShown = true - - measureScrollbars: -> - @measureScrollbarsWhenShown = false - - cornerNode = @scrollbarCornerComponent.getDomNode() - originalDisplayValue = cornerNode.style.display - - cornerNode.style.display = 'block' - - width = (cornerNode.offsetWidth - cornerNode.clientWidth) or 15 - height = (cornerNode.offsetHeight - cornerNode.clientHeight) or 15 - - @presenter.setVerticalScrollbarWidth(width) - @presenter.setHorizontalScrollbarHeight(height) - - cornerNode.style.display = originalDisplayValue - - containsScrollbarSelector: (stylesheet) -> - for rule in stylesheet.cssRules - if rule.selectorText?.indexOf('scrollbar') > -1 - return true - false - - refreshScrollbars: => - if @isVisible() - @measureScrollbarsWhenShown = false - else - @measureScrollbarsWhenShown = true - return - - verticalNode = @verticalScrollbarComponent.getDomNode() - horizontalNode = @horizontalScrollbarComponent.getDomNode() - cornerNode = @scrollbarCornerComponent.getDomNode() - - originalVerticalDisplayValue = verticalNode.style.display - originalHorizontalDisplayValue = horizontalNode.style.display - originalCornerDisplayValue = cornerNode.style.display - - # First, hide all scrollbars in case they are visible so they take on new - # styles when they are shown again. - verticalNode.style.display = 'none' - horizontalNode.style.display = 'none' - cornerNode.style.display = 'none' - - # Force a reflow - cornerNode.offsetWidth - - # Now measure the new scrollbar dimensions - @measureScrollbars() - - # Now restore the display value for all scrollbars, since they were - # previously hidden - verticalNode.style.display = originalVerticalDisplayValue - horizontalNode.style.display = originalHorizontalDisplayValue - cornerNode.style.display = originalCornerDisplayValue - - consolidateSelections: (e) -> - e.abortKeyBinding() unless @editor.consolidateSelections() - - lineNodeForScreenRow: (screenRow) -> - @linesComponent.lineNodeForScreenRow(screenRow) - - lineNumberNodeForScreenRow: (screenRow) -> - tileRow = @presenter.tileForRow(screenRow) - gutterComponent = @gutterContainerComponent.getLineNumberGutterComponent() - tileComponent = gutterComponent.getComponentForTile(tileRow) - - tileComponent?.lineNumberNodeForScreenRow(screenRow) - - tileNodesForLines: -> - @linesComponent.getTiles() - - tileNodesForLineNumbers: -> - gutterComponent = @gutterContainerComponent.getLineNumberGutterComponent() - gutterComponent.getTiles() - - screenRowForNode: (node) -> - while node? - if screenRow = node.dataset?.screenRow - return parseInt(screenRow) - node = node.parentElement - null - - getFontSize: -> - parseInt(getComputedStyle(@getTopmostDOMNode()).fontSize) - - setFontSize: (fontSize) -> - @getTopmostDOMNode().style.fontSize = fontSize + 'px' - @sampleFontStyling() - @invalidateMeasurements() - - getFontFamily: -> - getComputedStyle(@getTopmostDOMNode()).fontFamily - - setFontFamily: (fontFamily) -> - @getTopmostDOMNode().style.fontFamily = fontFamily - @sampleFontStyling() - @invalidateMeasurements() - - setLineHeight: (lineHeight) -> - @getTopmostDOMNode().style.lineHeight = lineHeight - @sampleFontStyling() - @invalidateMeasurements() - - invalidateMeasurements: -> - @linesYardstick.invalidateCache() - @presenter.measurementsChanged() - - screenPositionForMouseEvent: (event, linesClientRect) -> - pixelPosition = @pixelPositionForMouseEvent(event, linesClientRect) - @screenPositionForPixelPosition(pixelPosition) - - pixelPositionForMouseEvent: (event, linesClientRect) -> - {clientX, clientY} = event - - linesClientRect ?= @linesComponent.getDomNode().getBoundingClientRect() - top = clientY - linesClientRect.top + @presenter.getRealScrollTop() - left = clientX - linesClientRect.left + @presenter.getRealScrollLeft() - bottom = linesClientRect.top + @presenter.getRealScrollTop() + linesClientRect.height - clientY - right = linesClientRect.left + @presenter.getRealScrollLeft() + linesClientRect.width - clientX - - {top, left, bottom, right} - - getGutterWidth: -> - @presenter.getGutterWidth() - - getModel: -> - @editor - - isInputEnabled: -> @inputEnabled - - setInputEnabled: (@inputEnabled) -> @inputEnabled - - setContinuousReflow: (continuousReflow) -> - @presenter.setContinuousReflow(continuousReflow) - - updateParentViewFocusedClassIfNeeded: -> - if @oldState.focused isnt @newState.focused - @hostElement.classList.toggle('is-focused', @newState.focused) - @oldState.focused = @newState.focused - - updateParentViewMiniClass: -> - @hostElement.classList.toggle('mini', @editor.isMini()) diff --git a/src/text-editor-presenter.coffee b/src/text-editor-presenter.coffee deleted file mode 100644 index 1106cee09eb..00000000000 --- a/src/text-editor-presenter.coffee +++ /dev/null @@ -1,1562 +0,0 @@ -{CompositeDisposable, Emitter} = require 'event-kit' -{Point, Range} = require 'text-buffer' -_ = require 'underscore-plus' -Decoration = require './decoration' - -module.exports = -class TextEditorPresenter - toggleCursorBlinkHandle: null - startBlinkingCursorsAfterDelay: null - stoppedScrollingTimeoutId: null - mouseWheelScreenRow: null - overlayDimensions: null - minimumReflowInterval: 200 - - constructor: (params) -> - {@model, @lineTopIndex} = params - @model.presenter = this - {@cursorBlinkPeriod, @cursorBlinkResumeDelay, @stoppedScrollingDelay, @tileSize, @autoHeight} = params - {@contentFrameWidth} = params - {@displayLayer} = @model - - @gutterWidth = 0 - @tileSize ?= 6 - @realScrollTop = @scrollTop - @realScrollLeft = @scrollLeft - @disposables = new CompositeDisposable - @emitter = new Emitter - @linesByScreenRow = new Map - @visibleHighlights = {} - @characterWidthsByScope = {} - @lineDecorationsByScreenRow = {} - @lineNumberDecorationsByScreenRow = {} - @customGutterDecorationsByGutterName = {} - @overlayDimensions = {} - @observedBlockDecorations = new Set() - @invalidatedDimensionsByBlockDecoration = new Set() - @invalidateAllBlockDecorationsDimensions = false - @precedingBlockDecorationsByScreenRowAndId = {} - @followingBlockDecorationsByScreenRowAndId = {} - @screenRowsToMeasure = [] - @flashCountsByDecorationId = {} - @transferMeasurementsToModel() - @transferMeasurementsFromModel() - @observeModel() - @buildState() - @invalidateState() - @startBlinkingCursors() if @focused - @startReflowing() if @continuousReflow - @updating = false - - setLinesYardstick: (@linesYardstick) -> - - getLinesYardstick: -> @linesYardstick - - destroy: -> - @disposables.dispose() - clearTimeout(@stoppedScrollingTimeoutId) if @stoppedScrollingTimeoutId? - clearInterval(@reflowingInterval) if @reflowingInterval? - @stopBlinkingCursors() - - # Calls your `callback` when some changes in the model occurred and the current state has been updated. - onDidUpdateState: (callback) -> - @emitter.on 'did-update-state', callback - - emitDidUpdateState: -> - @emitter.emit "did-update-state" if @isBatching() - - transferMeasurementsToModel: -> - @model.setLineHeightInPixels(@lineHeight) if @lineHeight? - @model.setDefaultCharWidth(@baseCharacterWidth) if @baseCharacterWidth? - - transferMeasurementsFromModel: -> - @editorWidthInChars = @model.getEditorWidthInChars() - - # Private: Determines whether {TextEditorPresenter} is currently batching changes. - # Returns a {Boolean}, `true` if is collecting changes, `false` if is applying them. - isBatching: -> - @updating is false - - getPreMeasurementState: -> - @updating = true - - @updateVerticalDimensions() - @updateScrollbarDimensions() - - @commitPendingLogicalScrollTopPosition() - @commitPendingScrollTopPosition() - - @updateStartRow() - @updateEndRow() - @updateCommonGutterState() - @updateReflowState() - - @updateLines() - - if @shouldUpdateDecorations - @fetchDecorations() - @updateLineDecorations() - @updateBlockDecorations() - - @updateTilesState() - - @updating = false - @state - - getPostMeasurementState: -> - @updating = true - - @updateHorizontalDimensions() - @commitPendingLogicalScrollLeftPosition() - @commitPendingScrollLeftPosition() - @clearPendingScrollPosition() - @updateRowsPerPage() - - @updateLines() - - @updateVerticalScrollState() - @updateHorizontalScrollState() - @updateScrollbarsState() - @updateHiddenInputState() - @updateContentState() - @updateFocusedState() - @updateHeightState() - @updateWidthState() - @updateHighlightDecorations() if @shouldUpdateDecorations - @updateTilesState() - @updateCursorsState() - @updateOverlaysState() - @updateLineNumberGutterState() - @updateGutterOrderState() - @updateCustomGutterDecorationState() - @updating = false - - @resetTrackedUpdates() - @state - - resetTrackedUpdates: -> - @shouldUpdateDecorations = false - - invalidateState: -> - @shouldUpdateDecorations = true - - observeModel: -> - @disposables.add @model.displayLayer.onDidReset => - @spliceBlockDecorationsInRange(0, Infinity, Infinity) - @shouldUpdateDecorations = true - @emitDidUpdateState() - - @disposables.add @model.displayLayer.onDidChangeSync (changes) => - for change in changes - startRow = change.start.row - endRow = startRow + change.oldExtent.row - @spliceBlockDecorationsInRange(startRow, endRow, change.newExtent.row - change.oldExtent.row) - @shouldUpdateDecorations = true - @emitDidUpdateState() - - @disposables.add @model.onDidUpdateDecorations => - @shouldUpdateDecorations = true - @emitDidUpdateState() - - @disposables.add @model.onDidAddDecoration(@didAddBlockDecoration.bind(this)) - - for decoration in @model.getDecorations({type: 'block'}) - this.didAddBlockDecoration(decoration) - - @disposables.add @model.onDidChangeGrammar(@didChangeGrammar.bind(this)) - @disposables.add @model.onDidChangePlaceholderText(@emitDidUpdateState.bind(this)) - @disposables.add @model.onDidChangeMini => - @shouldUpdateDecorations = true - @emitDidUpdateState() - - @disposables.add @model.onDidChangeLineNumberGutterVisible(@emitDidUpdateState.bind(this)) - - @disposables.add @model.onDidAddCursor(@didAddCursor.bind(this)) - @disposables.add @model.onDidRequestAutoscroll(@requestAutoscroll.bind(this)) - @disposables.add @model.onDidChangeFirstVisibleScreenRow(@didChangeFirstVisibleScreenRow.bind(this)) - @observeCursor(cursor) for cursor in @model.getCursors() - @disposables.add @model.onDidAddGutter(@didAddGutter.bind(this)) - return - - didChangeScrollPastEnd: -> - @updateScrollHeight() - @emitDidUpdateState() - - didChangeShowLineNumbers: -> - @emitDidUpdateState() - - didChangeGrammar: -> - @emitDidUpdateState() - - buildState: -> - @state = - horizontalScrollbar: {} - verticalScrollbar: {} - hiddenInput: {} - content: - scrollingVertically: false - cursorsVisible: false - tiles: {} - highlights: {} - overlays: {} - cursors: {} - offScreenBlockDecorations: {} - gutters: [] - # Shared state that is copied into ``@state.gutters`. - @sharedGutterStyles = {} - @customGutterDecorations = {} - @lineNumberGutter = - tiles: {} - - setContinuousReflow: (@continuousReflow) -> - if @continuousReflow - @startReflowing() - else - @stopReflowing() - - updateReflowState: -> - @state.content.continuousReflow = @continuousReflow - @lineNumberGutter.continuousReflow = @continuousReflow - - startReflowing: -> - @reflowingInterval = setInterval(@emitDidUpdateState.bind(this), @minimumReflowInterval) - - stopReflowing: -> - clearInterval(@reflowingInterval) - @reflowingInterval = null - - updateFocusedState: -> - @state.focused = @focused - - updateHeightState: -> - if @autoHeight - @state.height = @contentHeight - else - @state.height = null - - updateWidthState: -> - if @model.getAutoWidth() - @state.width = @state.content.width + @gutterWidth - else - @state.width = null - - updateVerticalScrollState: -> - @state.content.scrollHeight = @scrollHeight - @sharedGutterStyles.scrollHeight = @scrollHeight - @state.verticalScrollbar.scrollHeight = @scrollHeight - - @state.content.scrollTop = @scrollTop - @sharedGutterStyles.scrollTop = @scrollTop - @state.verticalScrollbar.scrollTop = @scrollTop - - updateHorizontalScrollState: -> - @state.content.scrollWidth = @scrollWidth - @state.horizontalScrollbar.scrollWidth = @scrollWidth - - @state.content.scrollLeft = @scrollLeft - @state.horizontalScrollbar.scrollLeft = @scrollLeft - - updateScrollbarsState: -> - @state.horizontalScrollbar.visible = @horizontalScrollbarHeight > 0 - @state.horizontalScrollbar.height = @measuredHorizontalScrollbarHeight - @state.horizontalScrollbar.right = @verticalScrollbarWidth - - @state.verticalScrollbar.visible = @verticalScrollbarWidth > 0 - @state.verticalScrollbar.width = @measuredVerticalScrollbarWidth - @state.verticalScrollbar.bottom = @horizontalScrollbarHeight - - updateHiddenInputState: -> - return unless lastCursor = @model.getLastCursor() - - {top, left, height, width} = @pixelRectForScreenRange(lastCursor.getScreenRange()) - - if @focused - @state.hiddenInput.top = Math.max(Math.min(top, @clientHeight - height), 0) - @state.hiddenInput.left = Math.max(Math.min(left, @clientWidth - width), 0) - else - @state.hiddenInput.top = 0 - @state.hiddenInput.left = 0 - - @state.hiddenInput.height = height - @state.hiddenInput.width = Math.max(width, 2) - - updateContentState: -> - if @boundingClientRect? - @sharedGutterStyles.maxHeight = @boundingClientRect.height - @state.content.maxHeight = @boundingClientRect.height - - verticalScrollbarWidth = @verticalScrollbarWidth ? 0 - contentFrameWidth = @contentFrameWidth ? 0 - contentWidth = @contentWidth ? 0 - if @model.getAutoWidth() - @state.content.width = contentWidth + verticalScrollbarWidth - else - @state.content.width = Math.max(contentWidth + verticalScrollbarWidth, contentFrameWidth) - @state.content.scrollWidth = @scrollWidth - @state.content.scrollLeft = @scrollLeft - @state.content.backgroundColor = if @model.isMini() then null else @backgroundColor - @state.content.placeholderText = if @model.isEmpty() then @model.getPlaceholderText() else null - - tileForRow: (row) -> - row - (row % @tileSize) - - getStartTileRow: -> - @tileForRow(@startRow ? 0) - - getEndTileRow: -> - @tileForRow(@endRow ? 0) - - getScreenRowsToRender: -> - startRow = @getStartTileRow() - endRow = @getEndTileRow() + @tileSize - - screenRows = [startRow...endRow] - longestScreenRow = @model.getApproximateLongestScreenRow() - if longestScreenRow? - screenRows.push(longestScreenRow) - if @screenRowsToMeasure? - screenRows.push(@screenRowsToMeasure...) - - screenRows = screenRows.filter (row) -> row >= 0 - screenRows.sort (a, b) -> a - b - _.uniq(screenRows, true) - - getScreenRangesToRender: -> - screenRows = @getScreenRowsToRender() - screenRows.push(Infinity) # makes the loop below inclusive - - startRow = screenRows[0] - endRow = startRow - 1 - screenRanges = [] - for row in screenRows - if row is endRow + 1 - endRow++ - else - screenRanges.push([startRow, endRow]) - startRow = endRow = row - - screenRanges - - setScreenRowsToMeasure: (screenRows) -> - return if not screenRows? or screenRows.length is 0 - - @screenRowsToMeasure = screenRows - @shouldUpdateDecorations = true - - clearScreenRowsToMeasure: -> - @screenRowsToMeasure = [] - - updateTilesState: -> - return unless @startRow? and @endRow? and @lineHeight? - - screenRows = @getScreenRowsToRender() - visibleTiles = {} - startRow = screenRows[0] - endRow = screenRows[screenRows.length - 1] - screenRowIndex = screenRows.length - 1 - zIndex = 0 - - for tileStartRow in [@tileForRow(endRow)..@tileForRow(startRow)] by -@tileSize - tileEndRow = tileStartRow + @tileSize - rowsWithinTile = [] - - while screenRowIndex >= 0 - currentScreenRow = screenRows[screenRowIndex] - break if currentScreenRow < tileStartRow - rowsWithinTile.push(currentScreenRow) - screenRowIndex-- - - continue if rowsWithinTile.length is 0 - - top = Math.round(@lineTopIndex.pixelPositionBeforeBlocksForRow(tileStartRow)) - bottom = Math.round(@lineTopIndex.pixelPositionBeforeBlocksForRow(tileEndRow)) - height = bottom - top - - tile = @state.content.tiles[tileStartRow] ?= {} - tile.top = top - @scrollTop - tile.left = -@scrollLeft - tile.height = height - tile.display = "block" - tile.zIndex = zIndex - tile.highlights ?= {} - - gutterTile = @lineNumberGutter.tiles[tileStartRow] ?= {} - gutterTile.top = top - @scrollTop - gutterTile.height = height - gutterTile.display = "block" - gutterTile.zIndex = zIndex - - @updateLinesState(tile, rowsWithinTile) - @updateLineNumbersState(gutterTile, rowsWithinTile) - - visibleTiles[tileStartRow] = true - zIndex++ - - mouseWheelTileId = @tileForRow(@mouseWheelScreenRow) if @mouseWheelScreenRow? - - for id, tile of @state.content.tiles - continue if visibleTiles.hasOwnProperty(id) - - if Number(id) is mouseWheelTileId - @state.content.tiles[id].display = "none" - @lineNumberGutter.tiles[id].display = "none" - else - delete @state.content.tiles[id] - delete @lineNumberGutter.tiles[id] - - updateLinesState: (tileState, screenRows) -> - tileState.lines ?= {} - visibleLineIds = {} - for screenRow in screenRows - line = @linesByScreenRow.get(screenRow) - continue unless line? - - visibleLineIds[line.id] = true - precedingBlockDecorations = @precedingBlockDecorationsByScreenRowAndId[screenRow] ? {} - followingBlockDecorations = @followingBlockDecorationsByScreenRowAndId[screenRow] ? {} - if tileState.lines.hasOwnProperty(line.id) - lineState = tileState.lines[line.id] - lineState.screenRow = screenRow - lineState.decorationClasses = @lineDecorationClassesForRow(screenRow) - lineState.precedingBlockDecorations = precedingBlockDecorations - lineState.followingBlockDecorations = followingBlockDecorations - else - tileState.lines[line.id] = - screenRow: screenRow - lineText: line.lineText - tagCodes: line.tagCodes - decorationClasses: @lineDecorationClassesForRow(screenRow) - precedingBlockDecorations: precedingBlockDecorations - followingBlockDecorations: followingBlockDecorations - - for id, line of tileState.lines - delete tileState.lines[id] unless visibleLineIds.hasOwnProperty(id) - return - - updateCursorsState: -> - return unless @startRow? and @endRow? and @hasPixelRectRequirements() and @baseCharacterWidth? - - @state.content.cursors = {} - for cursor in @model.cursorsForScreenRowRange(@startRow, @endRow - 1) when cursor.isVisible() - pixelRect = @pixelRectForScreenRange(cursor.getScreenRange()) - pixelRect.width = Math.round(@baseCharacterWidth) if pixelRect.width is 0 - @state.content.cursors[cursor.id] = pixelRect - return - - updateOverlaysState: -> - return unless @hasOverlayPositionRequirements() - - visibleDecorationIds = {} - - for decoration in @model.getOverlayDecorations() - continue unless decoration.getMarker().isValid() - - {item, position, class: klass, avoidOverflow} = decoration.getProperties() - if position is 'tail' - screenPosition = decoration.getMarker().getTailScreenPosition() - else - screenPosition = decoration.getMarker().getHeadScreenPosition() - - pixelPosition = @pixelPositionForScreenPosition(screenPosition) - - # Fixed positioning. - top = @boundingClientRect.top + pixelPosition.top + @lineHeight - left = @boundingClientRect.left + pixelPosition.left + @gutterWidth - - if overlayDimensions = @overlayDimensions[decoration.id] - {itemWidth, itemHeight, contentMargin} = overlayDimensions - - if avoidOverflow isnt false - rightDiff = left + itemWidth + contentMargin - @windowWidth - left -= rightDiff if rightDiff > 0 - - leftDiff = left + contentMargin - left -= leftDiff if leftDiff < 0 - - if top + itemHeight > @windowHeight and - top - (itemHeight + @lineHeight) >= 0 - top -= itemHeight + @lineHeight - - pixelPosition.top = top - pixelPosition.left = left - - overlayState = @state.content.overlays[decoration.id] ?= {item} - overlayState.pixelPosition = pixelPosition - overlayState.class = klass if klass? - visibleDecorationIds[decoration.id] = true - - for id of @state.content.overlays - delete @state.content.overlays[id] unless visibleDecorationIds[id] - - for id of @overlayDimensions - delete @overlayDimensions[id] unless visibleDecorationIds[id] - - return - - updateLineNumberGutterState: -> - @lineNumberGutter.maxLineNumberDigits = Math.max( - 2, - @model.getLineCount().toString().length - ) - - updateCommonGutterState: -> - @sharedGutterStyles.backgroundColor = if @gutterBackgroundColor isnt "rgba(0, 0, 0, 0)" - @gutterBackgroundColor - else - @backgroundColor - - didAddGutter: (gutter) -> - gutterDisposables = new CompositeDisposable - gutterDisposables.add gutter.onDidChangeVisible => @emitDidUpdateState() - gutterDisposables.add gutter.onDidDestroy => - @disposables.remove(gutterDisposables) - gutterDisposables.dispose() - @emitDidUpdateState() - # It is not necessary to @updateCustomGutterDecorationState here. - # The destroyed gutter will be removed from the list of gutters in @state, - # and thus will be removed from the DOM. - @disposables.add(gutterDisposables) - @emitDidUpdateState() - - updateGutterOrderState: -> - @state.gutters = [] - if @model.isMini() - return - for gutter in @model.getGutters() - isVisible = @gutterIsVisible(gutter) - if gutter.name is 'line-number' - content = @lineNumberGutter - else - @customGutterDecorations[gutter.name] ?= {} - content = @customGutterDecorations[gutter.name] - @state.gutters.push({ - gutter, - visible: isVisible, - styles: @sharedGutterStyles, - content, - }) - - # Updates the decoration state for the gutter with the given gutterName. - # @customGutterDecorations is an {Object}, with the form: - # * gutterName : { - # decoration.id : { - # top: # of pixels from top - # height: # of pixels height of this decoration - # item (optional): HTMLElement - # class (optional): {String} class - # } - # } - updateCustomGutterDecorationState: -> - return unless @startRow? and @endRow? and @lineHeight? - - if @model.isMini() - # Mini editors have no gutter decorations. - # We clear instead of reassigning to preserve the reference. - @clearAllCustomGutterDecorations() - - for gutter in @model.getGutters() - gutterName = gutter.name - gutterDecorations = @customGutterDecorations[gutterName] - if gutterDecorations - # Clear the gutter decorations; they are rebuilt. - # We clear instead of reassigning to preserve the reference. - @clearDecorationsForCustomGutterName(gutterName) - else - @customGutterDecorations[gutterName] = {} - - continue unless @gutterIsVisible(gutter) - for decorationId, {properties, screenRange} of @customGutterDecorationsByGutterName[gutterName] - top = @lineTopIndex.pixelPositionAfterBlocksForRow(screenRange.start.row) - bottom = @lineTopIndex.pixelPositionBeforeBlocksForRow(screenRange.end.row + 1) - @customGutterDecorations[gutterName][decorationId] = - top: top - height: bottom - top - item: properties.item - class: properties.class - - clearAllCustomGutterDecorations: -> - allGutterNames = Object.keys(@customGutterDecorations) - for gutterName in allGutterNames - @clearDecorationsForCustomGutterName(gutterName) - - clearDecorationsForCustomGutterName: (gutterName) -> - gutterDecorations = @customGutterDecorations[gutterName] - if gutterDecorations - allDecorationIds = Object.keys(gutterDecorations) - for decorationId in allDecorationIds - delete gutterDecorations[decorationId] - - gutterIsVisible: (gutterModel) -> - isVisible = gutterModel.isVisible() - if gutterModel.name is 'line-number' - isVisible = isVisible and @model.doesShowLineNumbers() - isVisible - - updateLineNumbersState: (tileState, screenRows) -> - tileState.lineNumbers ?= {} - visibleLineNumberIds = {} - - for screenRow in screenRows when @isRowRendered(screenRow) - line = @linesByScreenRow.get(screenRow) - continue unless line? - lineId = line.id - {row: bufferRow, column: bufferColumn} = @displayLayer.translateScreenPosition(Point(screenRow, 0)) - softWrapped = bufferColumn isnt 0 - foldable = not softWrapped and @model.isFoldableAtBufferRow(bufferRow) - decorationClasses = @lineNumberDecorationClassesForRow(screenRow) - blockDecorationsBeforeCurrentScreenRowHeight = @lineTopIndex.pixelPositionAfterBlocksForRow(screenRow) - @lineTopIndex.pixelPositionBeforeBlocksForRow(screenRow) - blockDecorationsHeight = blockDecorationsBeforeCurrentScreenRowHeight - if screenRow % @tileSize isnt 0 - blockDecorationsAfterPreviousScreenRowHeight = @lineTopIndex.pixelPositionBeforeBlocksForRow(screenRow) - @lineHeight - @lineTopIndex.pixelPositionAfterBlocksForRow(screenRow - 1) - blockDecorationsHeight += blockDecorationsAfterPreviousScreenRowHeight - - tileState.lineNumbers[lineId] = {screenRow, bufferRow, softWrapped, decorationClasses, foldable, blockDecorationsHeight} - visibleLineNumberIds[lineId] = true - - for id of tileState.lineNumbers - delete tileState.lineNumbers[id] unless visibleLineNumberIds[id] - - return - - updateStartRow: -> - return unless @scrollTop? and @lineHeight? - - @startRow = Math.max(0, @lineTopIndex.rowForPixelPosition(@scrollTop)) - atom.assert( - Number.isFinite(@startRow), - 'Invalid start row', - (error) => - error.metadata = { - startRow: @startRow?.toString(), - scrollTop: @scrollTop?.toString(), - scrollHeight: @scrollHeight?.toString(), - clientHeight: @clientHeight?.toString(), - lineHeight: @lineHeight?.toString() - } - ) - - updateEndRow: -> - return unless @scrollTop? and @lineHeight? and @height? - - @endRow = Math.min( - @model.getApproximateScreenLineCount(), - @lineTopIndex.rowForPixelPosition(@scrollTop + @height + @lineHeight - 1) + 1 - ) - - updateRowsPerPage: -> - rowsPerPage = Math.floor(@getClientHeight() / @lineHeight) - if rowsPerPage isnt @rowsPerPage - @rowsPerPage = rowsPerPage - @model.setRowsPerPage(@rowsPerPage) - - updateScrollWidth: -> - return unless @contentWidth? and @clientWidth? - - scrollWidth = Math.max(@contentWidth, @clientWidth) - unless @scrollWidth is scrollWidth - @scrollWidth = scrollWidth - @updateScrollLeft(@scrollLeft) - - updateScrollHeight: -> - return unless @contentHeight? and @clientHeight? - - contentHeight = @contentHeight - if @model.getScrollPastEnd() - extraScrollHeight = @clientHeight - (@lineHeight * 3) - contentHeight += extraScrollHeight if extraScrollHeight > 0 - scrollHeight = Math.max(contentHeight, @height) - - unless @scrollHeight is scrollHeight - @scrollHeight = scrollHeight - @updateScrollTop(@scrollTop) - - updateVerticalDimensions: -> - if @lineHeight? - oldContentHeight = @contentHeight - @contentHeight = Math.round(@lineTopIndex.pixelPositionAfterBlocksForRow(@model.getApproximateScreenLineCount())) - - if @contentHeight isnt oldContentHeight - @updateHeight() - @updateScrollbarDimensions() - @updateScrollHeight() - - updateHorizontalDimensions: -> - if @baseCharacterWidth? - oldContentWidth = @contentWidth - rightmostPosition = @model.getApproximateRightmostScreenPosition() - @contentWidth = @pixelPositionForScreenPosition(rightmostPosition).left - @contentWidth += @scrollLeft - @contentWidth += 1 unless @model.isSoftWrapped() # account for cursor width - - if @contentWidth isnt oldContentWidth - @updateScrollbarDimensions() - @updateClientWidth() - @updateScrollWidth() - - updateClientHeight: -> - return unless @height? and @horizontalScrollbarHeight? - - clientHeight = @height - @horizontalScrollbarHeight - @model.setHeight(clientHeight, true) - - unless @clientHeight is clientHeight - @clientHeight = clientHeight - @updateScrollHeight() - @updateScrollTop(@scrollTop) - - updateClientWidth: -> - return unless @contentFrameWidth? and @verticalScrollbarWidth? - - if @model.getAutoWidth() - clientWidth = @contentWidth - else - clientWidth = @contentFrameWidth - @verticalScrollbarWidth - - @model.setWidth(clientWidth, true) unless @editorWidthInChars - - unless @clientWidth is clientWidth - @clientWidth = clientWidth - @updateScrollWidth() - @updateScrollLeft(@scrollLeft) - - updateScrollTop: (scrollTop) -> - scrollTop = @constrainScrollTop(scrollTop) - if scrollTop isnt @realScrollTop and not Number.isNaN(scrollTop) - @realScrollTop = scrollTop - @scrollTop = Math.round(scrollTop) - @model.setFirstVisibleScreenRow(Math.round(@scrollTop / @lineHeight), true) - - @updateStartRow() - @updateEndRow() - @didStartScrolling() - @emitter.emit 'did-change-scroll-top', @scrollTop - - constrainScrollTop: (scrollTop) -> - return scrollTop unless scrollTop? and @scrollHeight? and @clientHeight? - Math.max(0, Math.min(scrollTop, @scrollHeight - @clientHeight)) - - updateScrollLeft: (scrollLeft) -> - scrollLeft = @constrainScrollLeft(scrollLeft) - if scrollLeft isnt @realScrollLeft and not Number.isNaN(scrollLeft) - @realScrollLeft = scrollLeft - @scrollLeft = Math.round(scrollLeft) - @model.setFirstVisibleScreenColumn(Math.round(@scrollLeft / @baseCharacterWidth)) - - @emitter.emit 'did-change-scroll-left', @scrollLeft - - constrainScrollLeft: (scrollLeft) -> - return scrollLeft unless scrollLeft? and @scrollWidth? and @clientWidth? - Math.max(0, Math.min(scrollLeft, @scrollWidth - @clientWidth)) - - updateScrollbarDimensions: -> - return unless @contentFrameWidth? and @height? - return unless @measuredVerticalScrollbarWidth? and @measuredHorizontalScrollbarHeight? - return unless @contentWidth? and @contentHeight? - - if @model.getAutoWidth() - clientWidthWithVerticalScrollbar = @contentWidth + @measuredVerticalScrollbarWidth - else - clientWidthWithVerticalScrollbar = @contentFrameWidth - clientWidthWithoutVerticalScrollbar = clientWidthWithVerticalScrollbar - @measuredVerticalScrollbarWidth - clientHeightWithHorizontalScrollbar = @height - clientHeightWithoutHorizontalScrollbar = clientHeightWithHorizontalScrollbar - @measuredHorizontalScrollbarHeight - - horizontalScrollbarVisible = - not @model.isMini() and - (@contentWidth > clientWidthWithVerticalScrollbar or - @contentWidth > clientWidthWithoutVerticalScrollbar and @contentHeight > clientHeightWithHorizontalScrollbar) - - verticalScrollbarVisible = - not @model.isMini() and - (@contentHeight > clientHeightWithHorizontalScrollbar or - @contentHeight > clientHeightWithoutHorizontalScrollbar and @contentWidth > clientWidthWithVerticalScrollbar) - - horizontalScrollbarHeight = - if horizontalScrollbarVisible - @measuredHorizontalScrollbarHeight - else - 0 - - verticalScrollbarWidth = - if verticalScrollbarVisible - @measuredVerticalScrollbarWidth - else - 0 - - unless @horizontalScrollbarHeight is horizontalScrollbarHeight - @horizontalScrollbarHeight = horizontalScrollbarHeight - @updateClientHeight() - - unless @verticalScrollbarWidth is verticalScrollbarWidth - @verticalScrollbarWidth = verticalScrollbarWidth - @updateClientWidth() - - lineDecorationClassesForRow: (row) -> - return null if @model.isMini() - - decorationClasses = null - for id, properties of @lineDecorationsByScreenRow[row] - decorationClasses ?= [] - decorationClasses.push(properties.class) - decorationClasses - - lineNumberDecorationClassesForRow: (row) -> - return null if @model.isMini() - - decorationClasses = null - for id, properties of @lineNumberDecorationsByScreenRow[row] - decorationClasses ?= [] - decorationClasses.push(properties.class) - decorationClasses - - getCursorBlinkPeriod: -> @cursorBlinkPeriod - - getCursorBlinkResumeDelay: -> @cursorBlinkResumeDelay - - setFocused: (focused) -> - unless @focused is focused - @focused = focused - if @focused - @startBlinkingCursors() - else - @stopBlinkingCursors(false) - @emitDidUpdateState() - - setScrollTop: (scrollTop) -> - return unless scrollTop? - - @pendingScrollLogicalPosition = null - @pendingScrollTop = scrollTop - - @shouldUpdateDecorations = true - @emitDidUpdateState() - - getScrollTop: -> - @scrollTop - - getRealScrollTop: -> - @realScrollTop ? @scrollTop - - didStartScrolling: -> - if @stoppedScrollingTimeoutId? - clearTimeout(@stoppedScrollingTimeoutId) - @stoppedScrollingTimeoutId = null - @stoppedScrollingTimeoutId = setTimeout(@didStopScrolling.bind(this), @stoppedScrollingDelay) - - didStopScrolling: -> - if @mouseWheelScreenRow? - @mouseWheelScreenRow = null - @shouldUpdateDecorations = true - - @emitDidUpdateState() - - setScrollLeft: (scrollLeft) -> - return unless scrollLeft? - - @pendingScrollLogicalPosition = null - @pendingScrollLeft = scrollLeft - - @emitDidUpdateState() - - getScrollLeft: -> - @scrollLeft - - getRealScrollLeft: -> - @realScrollLeft ? @scrollLeft - - getClientHeight: -> - if @clientHeight - @clientHeight - else - @explicitHeight - @horizontalScrollbarHeight - - getClientWidth: -> - if @clientWidth - @clientWidth - else - @contentFrameWidth - @verticalScrollbarWidth - - getScrollBottom: -> @getScrollTop() + @getClientHeight() - setScrollBottom: (scrollBottom) -> - @setScrollTop(scrollBottom - @getClientHeight()) - @getScrollBottom() - - getScrollRight: -> @getScrollLeft() + @getClientWidth() - setScrollRight: (scrollRight) -> - @setScrollLeft(scrollRight - @getClientWidth()) - @getScrollRight() - - getScrollHeight: -> - @scrollHeight - - getScrollWidth: -> - @scrollWidth - - getMaxScrollTop: -> - scrollHeight = @getScrollHeight() - clientHeight = @getClientHeight() - return 0 unless scrollHeight? and clientHeight? - - scrollHeight - clientHeight - - setHorizontalScrollbarHeight: (horizontalScrollbarHeight) -> - unless @measuredHorizontalScrollbarHeight is horizontalScrollbarHeight - @measuredHorizontalScrollbarHeight = horizontalScrollbarHeight - @emitDidUpdateState() - - setVerticalScrollbarWidth: (verticalScrollbarWidth) -> - unless @measuredVerticalScrollbarWidth is verticalScrollbarWidth - @measuredVerticalScrollbarWidth = verticalScrollbarWidth - @emitDidUpdateState() - - setAutoHeight: (autoHeight) -> - unless @autoHeight is autoHeight - @autoHeight = autoHeight - @emitDidUpdateState() - - setExplicitHeight: (explicitHeight) -> - unless @explicitHeight is explicitHeight - @explicitHeight = explicitHeight - @updateHeight() - @shouldUpdateDecorations = true - @emitDidUpdateState() - - updateHeight: -> - height = @explicitHeight ? @contentHeight - unless @height is height - @height = height - @updateScrollbarDimensions() - @updateClientHeight() - @updateScrollHeight() - @updateEndRow() - - didChangeAutoWidth: -> - @emitDidUpdateState() - - setContentFrameWidth: (contentFrameWidth) -> - if @contentFrameWidth isnt contentFrameWidth or @editorWidthInChars? - @contentFrameWidth = contentFrameWidth - @editorWidthInChars = null - @updateScrollbarDimensions() - @updateClientWidth() - @invalidateAllBlockDecorationsDimensions = true - @shouldUpdateDecorations = true - @emitDidUpdateState() - - setBoundingClientRect: (boundingClientRect) -> - unless @clientRectsEqual(@boundingClientRect, boundingClientRect) - @boundingClientRect = boundingClientRect - @invalidateAllBlockDecorationsDimensions = true - @shouldUpdateDecorations = true - @emitDidUpdateState() - - clientRectsEqual: (clientRectA, clientRectB) -> - clientRectA? and clientRectB? and - clientRectA.top is clientRectB.top and - clientRectA.left is clientRectB.left and - clientRectA.width is clientRectB.width and - clientRectA.height is clientRectB.height - - setWindowSize: (width, height) -> - if @windowWidth isnt width or @windowHeight isnt height - @windowWidth = width - @windowHeight = height - @invalidateAllBlockDecorationsDimensions = true - @shouldUpdateDecorations = true - - @emitDidUpdateState() - - setBackgroundColor: (backgroundColor) -> - unless @backgroundColor is backgroundColor - @backgroundColor = backgroundColor - @emitDidUpdateState() - - setGutterBackgroundColor: (gutterBackgroundColor) -> - unless @gutterBackgroundColor is gutterBackgroundColor - @gutterBackgroundColor = gutterBackgroundColor - @emitDidUpdateState() - - setGutterWidth: (gutterWidth) -> - if @gutterWidth isnt gutterWidth - @gutterWidth = gutterWidth - @updateOverlaysState() - - getGutterWidth: -> - @gutterWidth - - setLineHeight: (lineHeight) -> - unless @lineHeight is lineHeight - @lineHeight = lineHeight - @model.setLineHeightInPixels(@lineHeight) - @lineTopIndex.setDefaultLineHeight(@lineHeight) - @restoreScrollTopIfNeeded() - @model.setLineHeightInPixels(lineHeight) - @shouldUpdateDecorations = true - @emitDidUpdateState() - - setMouseWheelScreenRow: (screenRow) -> - if @mouseWheelScreenRow isnt screenRow - @mouseWheelScreenRow = screenRow - @didStartScrolling() - - setBaseCharacterWidth: (baseCharacterWidth, doubleWidthCharWidth, halfWidthCharWidth, koreanCharWidth) -> - unless @baseCharacterWidth is baseCharacterWidth and @doubleWidthCharWidth is doubleWidthCharWidth and @halfWidthCharWidth is halfWidthCharWidth and koreanCharWidth is @koreanCharWidth - @baseCharacterWidth = baseCharacterWidth - @doubleWidthCharWidth = doubleWidthCharWidth - @halfWidthCharWidth = halfWidthCharWidth - @koreanCharWidth = koreanCharWidth - @model.setDefaultCharWidth(baseCharacterWidth, doubleWidthCharWidth, halfWidthCharWidth, koreanCharWidth) - @restoreScrollLeftIfNeeded() - @measurementsChanged() - - measurementsChanged: -> - @invalidateAllBlockDecorationsDimensions = true - @shouldUpdateDecorations = true - @emitDidUpdateState() - - hasPixelPositionRequirements: -> - @lineHeight? and @baseCharacterWidth? - - pixelPositionForScreenPosition: (screenPosition) -> - position = @linesYardstick.pixelPositionForScreenPosition(screenPosition) - position.top -= @getScrollTop() - position.left -= @getScrollLeft() - - position.top = Math.round(position.top) - position.left = Math.round(position.left) - - position - - hasPixelRectRequirements: -> - @hasPixelPositionRequirements() and @scrollWidth? - - hasOverlayPositionRequirements: -> - @hasPixelRectRequirements() and @boundingClientRect? and @windowWidth and @windowHeight - - absolutePixelRectForScreenRange: (screenRange) -> - lineHeight = @model.getLineHeightInPixels() - - if screenRange.end.row > screenRange.start.row - top = @linesYardstick.pixelPositionForScreenPosition(screenRange.start).top - left = 0 - height = (screenRange.end.row - screenRange.start.row + 1) * lineHeight - width = @getScrollWidth() - else - {top, left} = @linesYardstick.pixelPositionForScreenPosition(screenRange.start) - height = lineHeight - width = @linesYardstick.pixelPositionForScreenPosition(screenRange.end).left - left - - {top, left, width, height} - - pixelRectForScreenRange: (screenRange) -> - rect = @absolutePixelRectForScreenRange(screenRange) - rect.top -= @getScrollTop() - rect.left -= @getScrollLeft() - rect.top = Math.round(rect.top) - rect.left = Math.round(rect.left) - rect.width = Math.round(rect.width) - rect.height = Math.round(rect.height) - rect - - updateLines: -> - @linesByScreenRow.clear() - - for [startRow, endRow] in @getScreenRangesToRender() - for line, index in @displayLayer.getScreenLines(startRow, endRow + 1) - @linesByScreenRow.set(startRow + index, line) - - lineIdForScreenRow: (screenRow) -> - @linesByScreenRow.get(screenRow)?.id - - fetchDecorations: -> - return unless 0 <= @startRow <= @endRow <= Infinity - @decorations = @model.decorationsStateForScreenRowRange(@startRow, @endRow - 1) - - updateBlockDecorations: -> - if @invalidateAllBlockDecorationsDimensions - for decoration in @model.getDecorations(type: 'block') - @invalidatedDimensionsByBlockDecoration.add(decoration) - @invalidateAllBlockDecorationsDimensions = false - - visibleDecorationsById = {} - visibleDecorationsByScreenRowAndId = {} - for markerId, decorations of @model.decorationsForScreenRowRange(@getStartTileRow(), @getEndTileRow() + @tileSize - 1) - for decoration in decorations when decoration.isType('block') - screenRow = decoration.getMarker().getHeadScreenPosition().row - if decoration.getProperties().position is "after" - @followingBlockDecorationsByScreenRowAndId[screenRow] ?= {} - @followingBlockDecorationsByScreenRowAndId[screenRow][decoration.id] = {screenRow, decoration} - else - @precedingBlockDecorationsByScreenRowAndId[screenRow] ?= {} - @precedingBlockDecorationsByScreenRowAndId[screenRow][decoration.id] = {screenRow, decoration} - visibleDecorationsById[decoration.id] = true - visibleDecorationsByScreenRowAndId[screenRow] ?= {} - visibleDecorationsByScreenRowAndId[screenRow][decoration.id] = true - - for screenRow, blockDecorations of @precedingBlockDecorationsByScreenRowAndId - if Number(screenRow) isnt @mouseWheelScreenRow - for id, blockDecoration of blockDecorations - unless visibleDecorationsByScreenRowAndId[screenRow]?[id] - delete @precedingBlockDecorationsByScreenRowAndId[screenRow][id] - - for screenRow, blockDecorations of @followingBlockDecorationsByScreenRowAndId - if Number(screenRow) isnt @mouseWheelScreenRow - for id, blockDecoration of blockDecorations - unless visibleDecorationsByScreenRowAndId[screenRow]?[id] - delete @followingBlockDecorationsByScreenRowAndId[screenRow][id] - - @state.content.offScreenBlockDecorations = {} - @invalidatedDimensionsByBlockDecoration.forEach (decoration) => - unless visibleDecorationsById[decoration.id] - @state.content.offScreenBlockDecorations[decoration.id] = decoration - - updateLineDecorations: -> - @lineDecorationsByScreenRow = {} - @lineNumberDecorationsByScreenRow = {} - @customGutterDecorationsByGutterName = {} - - for decorationId, decorationState of @decorations - {properties, bufferRange, screenRange, rangeIsReversed} = decorationState - if Decoration.isType(properties, 'line') or Decoration.isType(properties, 'line-number') - @addToLineDecorationCaches(decorationId, properties, bufferRange, screenRange, rangeIsReversed) - - else if Decoration.isType(properties, 'gutter') and properties.gutterName? - @customGutterDecorationsByGutterName[properties.gutterName] ?= {} - @customGutterDecorationsByGutterName[properties.gutterName][decorationId] = decorationState - - return - - updateHighlightDecorations: -> - @visibleHighlights = {} - - for decorationId, {properties, screenRange} of @decorations - if Decoration.isType(properties, 'highlight') - @updateHighlightState(decorationId, properties, screenRange) - - for tileId, tileState of @state.content.tiles - for id of tileState.highlights - delete tileState.highlights[id] unless @visibleHighlights[tileId]?[id]? - - return - - addToLineDecorationCaches: (decorationId, properties, bufferRange, screenRange, rangeIsReversed) -> - if screenRange.isEmpty() - return if properties.onlyNonEmpty - else - return if properties.onlyEmpty - omitLastRow = screenRange.end.column is 0 - - if rangeIsReversed - headScreenPosition = screenRange.start - else - headScreenPosition = screenRange.end - - if properties.class is 'folded' and Decoration.isType(properties, 'line-number') - screenRow = @model.screenRowForBufferRow(bufferRange.start.row) - @lineNumberDecorationsByScreenRow[screenRow] ?= {} - @lineNumberDecorationsByScreenRow[screenRow][decorationId] = properties - else - startRow = Math.max(screenRange.start.row, @getStartTileRow()) - endRow = Math.min(screenRange.end.row, @getEndTileRow() + @tileSize) - for row in [startRow..endRow] by 1 - continue if properties.onlyHead and row isnt headScreenPosition.row - continue if omitLastRow and row is screenRange.end.row - - if Decoration.isType(properties, 'line') - @lineDecorationsByScreenRow[row] ?= {} - @lineDecorationsByScreenRow[row][decorationId] = properties - - if Decoration.isType(properties, 'line-number') - @lineNumberDecorationsByScreenRow[row] ?= {} - @lineNumberDecorationsByScreenRow[row][decorationId] = properties - - return - - intersectRangeWithTile: (range, tileStartRow) -> - intersectingStartRow = Math.max(tileStartRow, range.start.row) - intersectingEndRow = Math.min(tileStartRow + @tileSize - 1, range.end.row) - intersectingRange = new Range( - new Point(intersectingStartRow, 0), - new Point(intersectingEndRow, Infinity) - ) - - if intersectingStartRow is range.start.row - intersectingRange.start.column = range.start.column - - if intersectingEndRow is range.end.row - intersectingRange.end.column = range.end.column - - intersectingRange - - updateHighlightState: (decorationId, properties, screenRange) -> - return unless @startRow? and @endRow? and @lineHeight? and @hasPixelPositionRequirements() - - @constrainRangeToVisibleRowRange(screenRange) - - return if screenRange.isEmpty() - - startTile = @tileForRow(screenRange.start.row) - endTile = @tileForRow(screenRange.end.row) - needsFlash = properties.flashCount? and @flashCountsByDecorationId[decorationId] isnt properties.flashCount - if needsFlash - @flashCountsByDecorationId[decorationId] = properties.flashCount - - for tileStartRow in [startTile..endTile] by @tileSize - rangeWithinTile = @intersectRangeWithTile(screenRange, tileStartRow) - - continue if rangeWithinTile.isEmpty() - - tileState = @state.content.tiles[tileStartRow] ?= {highlights: {}} - highlightState = tileState.highlights[decorationId] ?= {} - - highlightState.needsFlash = needsFlash - highlightState.flashCount = properties.flashCount - highlightState.flashClass = properties.flashClass - highlightState.flashDuration = properties.flashDuration - highlightState.class = properties.class - highlightState.deprecatedRegionClass = properties.deprecatedRegionClass - highlightState.regions = @buildHighlightRegions(rangeWithinTile) - - for region in highlightState.regions - @repositionRegionWithinTile(region, tileStartRow) - - @visibleHighlights[tileStartRow] ?= {} - @visibleHighlights[tileStartRow][decorationId] = true - - true - - constrainRangeToVisibleRowRange: (screenRange) -> - if screenRange.start.row < @startRow - screenRange.start.row = @startRow - screenRange.start.column = 0 - - if screenRange.end.row < @startRow - screenRange.end.row = @startRow - screenRange.end.column = 0 - - if screenRange.start.row >= @endRow - screenRange.start.row = @endRow - screenRange.start.column = 0 - - if screenRange.end.row >= @endRow - screenRange.end.row = @endRow - screenRange.end.column = 0 - - repositionRegionWithinTile: (region, tileStartRow) -> - region.top += @scrollTop - @lineTopIndex.pixelPositionBeforeBlocksForRow(tileStartRow) - - buildHighlightRegions: (screenRange) -> - lineHeightInPixels = @lineHeight - startPixelPosition = @pixelPositionForScreenPosition(screenRange.start) - endPixelPosition = @pixelPositionForScreenPosition(screenRange.end) - startPixelPosition.left += @scrollLeft - endPixelPosition.left += @scrollLeft - spannedRows = screenRange.end.row - screenRange.start.row + 1 - - regions = [] - - if spannedRows is 1 - region = - top: startPixelPosition.top - height: lineHeightInPixels - left: startPixelPosition.left - - if screenRange.end.column is Infinity - region.right = 0 - else - region.width = endPixelPosition.left - startPixelPosition.left - - regions.push(region) - else - # First row, extending from selection start to the right side of screen - regions.push( - top: startPixelPosition.top - left: startPixelPosition.left - height: lineHeightInPixels - right: 0 - ) - - # Middle rows, extending from left side to right side of screen - if spannedRows > 2 - regions.push( - top: startPixelPosition.top + lineHeightInPixels - height: endPixelPosition.top - startPixelPosition.top - lineHeightInPixels - left: 0 - right: 0 - ) - - # Last row, extending from left side of screen to selection end - if screenRange.end.column > 0 - region = - top: endPixelPosition.top - height: lineHeightInPixels - left: 0 - - if screenRange.end.column is Infinity - region.right = 0 - else - region.width = endPixelPosition.left - - regions.push(region) - - regions - - setOverlayDimensions: (decorationId, itemWidth, itemHeight, contentMargin) -> - @overlayDimensions[decorationId] ?= {} - overlayState = @overlayDimensions[decorationId] - dimensionsAreEqual = overlayState.itemWidth is itemWidth and - overlayState.itemHeight is itemHeight and - overlayState.contentMargin is contentMargin - unless dimensionsAreEqual - overlayState.itemWidth = itemWidth - overlayState.itemHeight = itemHeight - overlayState.contentMargin = contentMargin - - @emitDidUpdateState() - - setBlockDecorationDimensions: (decoration, width, height) -> - return unless @observedBlockDecorations.has(decoration) - - @lineTopIndex.resizeBlock(decoration.id, height) - - @invalidatedDimensionsByBlockDecoration.delete(decoration) - @shouldUpdateDecorations = true - @emitDidUpdateState() - - invalidateBlockDecorationDimensions: (decoration) -> - @invalidatedDimensionsByBlockDecoration.add(decoration) - @shouldUpdateDecorations = true - @emitDidUpdateState() - - spliceBlockDecorationsInRange: (start, end, screenDelta) -> - return if screenDelta is 0 - - oldExtent = end - start - newExtent = end - start + screenDelta - invalidatedBlockDecorationIds = @lineTopIndex.splice(start, oldExtent, newExtent) - invalidatedBlockDecorationIds.forEach (id) => - decoration = @model.decorationForId(id) - newScreenPosition = decoration.getMarker().getHeadScreenPosition() - @lineTopIndex.moveBlock(id, newScreenPosition.row) - @invalidatedDimensionsByBlockDecoration.add(decoration) - - didAddBlockDecoration: (decoration) -> - return if not decoration.isType('block') or @observedBlockDecorations.has(decoration) - - didMoveDisposable = decoration.getMarker().bufferMarker.onDidChange (markerEvent) => - @didMoveBlockDecoration(decoration, markerEvent) - - didDestroyDisposable = decoration.onDidDestroy => - @disposables.remove(didMoveDisposable) - @disposables.remove(didDestroyDisposable) - didMoveDisposable.dispose() - didDestroyDisposable.dispose() - @didDestroyBlockDecoration(decoration) - - isAfter = decoration.getProperties().position is "after" - @lineTopIndex.insertBlock(decoration.id, decoration.getMarker().getHeadScreenPosition().row, 0, isAfter) - - @observedBlockDecorations.add(decoration) - @invalidateBlockDecorationDimensions(decoration) - @disposables.add(didMoveDisposable) - @disposables.add(didDestroyDisposable) - @shouldUpdateDecorations = true - @emitDidUpdateState() - - didMoveBlockDecoration: (decoration, markerEvent) -> - # Don't move blocks after a text change, because we already splice on buffer - # change. - return if markerEvent.textChanged - - @lineTopIndex.moveBlock(decoration.id, decoration.getMarker().getHeadScreenPosition().row) - @shouldUpdateDecorations = true - @emitDidUpdateState() - - didDestroyBlockDecoration: (decoration) -> - return unless @observedBlockDecorations.has(decoration) - - @lineTopIndex.removeBlock(decoration.id) - @observedBlockDecorations.delete(decoration) - @invalidatedDimensionsByBlockDecoration.delete(decoration) - @shouldUpdateDecorations = true - @emitDidUpdateState() - - observeCursor: (cursor) -> - didChangePositionDisposable = cursor.onDidChangePosition => - @pauseCursorBlinking() - - @emitDidUpdateState() - - didChangeVisibilityDisposable = cursor.onDidChangeVisibility => - - @emitDidUpdateState() - - didDestroyDisposable = cursor.onDidDestroy => - @disposables.remove(didChangePositionDisposable) - @disposables.remove(didChangeVisibilityDisposable) - @disposables.remove(didDestroyDisposable) - - @emitDidUpdateState() - - @disposables.add(didChangePositionDisposable) - @disposables.add(didChangeVisibilityDisposable) - @disposables.add(didDestroyDisposable) - - didAddCursor: (cursor) -> - @observeCursor(cursor) - @pauseCursorBlinking() - - @emitDidUpdateState() - - startBlinkingCursors: -> - unless @isCursorBlinking() - @state.content.cursorsVisible = true - @toggleCursorBlinkHandle = setInterval(@toggleCursorBlink.bind(this), @getCursorBlinkPeriod() / 2) - - isCursorBlinking: -> - @toggleCursorBlinkHandle? - - stopBlinkingCursors: (visible) -> - if @isCursorBlinking() - @state.content.cursorsVisible = visible - clearInterval(@toggleCursorBlinkHandle) - @toggleCursorBlinkHandle = null - - toggleCursorBlink: -> - @state.content.cursorsVisible = not @state.content.cursorsVisible - @emitDidUpdateState() - - pauseCursorBlinking: -> - @stopBlinkingCursors(true) - @startBlinkingCursorsAfterDelay ?= _.debounce(@startBlinkingCursors, @getCursorBlinkResumeDelay()) - @startBlinkingCursorsAfterDelay() - @emitDidUpdateState() - - requestAutoscroll: (position) -> - @pendingScrollLogicalPosition = position - @pendingScrollTop = null - @pendingScrollLeft = null - @shouldUpdateDecorations = true - @emitDidUpdateState() - - didChangeFirstVisibleScreenRow: (screenRow) -> - @setScrollTop(@lineTopIndex.pixelPositionAfterBlocksForRow(screenRow)) - - getVerticalScrollMarginInPixels: -> - Math.round(@model.getVerticalScrollMargin() * @lineHeight) - - getHorizontalScrollMarginInPixels: -> - Math.round(@model.getHorizontalScrollMargin() * @baseCharacterWidth) - - getVerticalScrollbarWidth: -> - @verticalScrollbarWidth - - getHorizontalScrollbarHeight: -> - @horizontalScrollbarHeight - - commitPendingLogicalScrollTopPosition: -> - return unless @pendingScrollLogicalPosition? - - {screenRange, options} = @pendingScrollLogicalPosition - - verticalScrollMarginInPixels = @getVerticalScrollMarginInPixels() - - top = @lineTopIndex.pixelPositionAfterBlocksForRow(screenRange.start.row) - bottom = @lineTopIndex.pixelPositionAfterBlocksForRow(screenRange.end.row) + @lineHeight - - if options?.center - desiredScrollCenter = (top + bottom) / 2 - unless @getScrollTop() < desiredScrollCenter < @getScrollBottom() - desiredScrollTop = desiredScrollCenter - @getClientHeight() / 2 - desiredScrollBottom = desiredScrollCenter + @getClientHeight() / 2 - else - desiredScrollTop = top - verticalScrollMarginInPixels - desiredScrollBottom = bottom + verticalScrollMarginInPixels - - if options?.reversed ? true - if desiredScrollBottom > @getScrollBottom() - @updateScrollTop(desiredScrollBottom - @getClientHeight()) - if desiredScrollTop < @getScrollTop() - @updateScrollTop(desiredScrollTop) - else - if desiredScrollTop < @getScrollTop() - @updateScrollTop(desiredScrollTop) - if desiredScrollBottom > @getScrollBottom() - @updateScrollTop(desiredScrollBottom - @getClientHeight()) - - commitPendingLogicalScrollLeftPosition: -> - return unless @pendingScrollLogicalPosition? - - {screenRange, options} = @pendingScrollLogicalPosition - - horizontalScrollMarginInPixels = @getHorizontalScrollMarginInPixels() - - {left} = @pixelRectForScreenRange(new Range(screenRange.start, screenRange.start)) - {left: right} = @pixelRectForScreenRange(new Range(screenRange.end, screenRange.end)) - - left += @scrollLeft - right += @scrollLeft - - desiredScrollLeft = left - horizontalScrollMarginInPixels - desiredScrollRight = right + horizontalScrollMarginInPixels - - if options?.reversed ? true - if desiredScrollRight > @getScrollRight() - @updateScrollLeft(desiredScrollRight - @getClientWidth()) - if desiredScrollLeft < @getScrollLeft() - @updateScrollLeft(desiredScrollLeft) - else - if desiredScrollLeft < @getScrollLeft() - @updateScrollLeft(desiredScrollLeft) - if desiredScrollRight > @getScrollRight() - @updateScrollLeft(desiredScrollRight - @getClientWidth()) - - commitPendingScrollLeftPosition: -> - if @pendingScrollLeft? - @updateScrollLeft(@pendingScrollLeft) - @pendingScrollLeft = null - - commitPendingScrollTopPosition: -> - if @pendingScrollTop? - @updateScrollTop(@pendingScrollTop) - @pendingScrollTop = null - - clearPendingScrollPosition: -> - @pendingScrollLogicalPosition = null - @pendingScrollTop = null - @pendingScrollLeft = null - - canScrollLeftTo: (scrollLeft) -> - @scrollLeft isnt @constrainScrollLeft(scrollLeft) - - canScrollTopTo: (scrollTop) -> - @scrollTop isnt @constrainScrollTop(scrollTop) - - restoreScrollTopIfNeeded: -> - unless @scrollTop? - @updateScrollTop(@lineTopIndex.pixelPositionAfterBlocksForRow(@model.getFirstVisibleScreenRow())) - - restoreScrollLeftIfNeeded: -> - unless @scrollLeft? - @updateScrollLeft(@model.getFirstVisibleScreenColumn() * @baseCharacterWidth) - - onDidChangeScrollTop: (callback) -> - @emitter.on 'did-change-scroll-top', callback - - onDidChangeScrollLeft: (callback) -> - @emitter.on 'did-change-scroll-left', callback - - getVisibleRowRange: -> - [@startRow, @endRow] - - isRowRendered: (row) -> - @getStartTileRow() <= row < @getEndTileRow() + @tileSize - - isOpenTagCode: (tagCode) -> - @displayLayer.isOpenTagCode(tagCode) - - isCloseTagCode: (tagCode) -> - @displayLayer.isCloseTagCode(tagCode) - - tagForCode: (tagCode) -> - @displayLayer.tagForCode(tagCode) From c8f2fbb657d6b94eff5d2e01d39a8d06ce6ee760 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sat, 15 Apr 2017 12:30:33 -0600 Subject: [PATCH 242/403] Get TextEditorElement tests passing --- package.json | 2 +- spec/text-editor-element-spec.coffee | 75 +++++++-------------------- src/text-editor-component.js | 73 ++++++++++++++------------ src/text-editor-element.js | 77 +++++++++++++++++++++++++++- src/text-editor.coffee | 1 + 5 files changed, 136 insertions(+), 92 deletions(-) diff --git a/package.json b/package.json index b058370503f..6eb57d2fec5 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "color": "^0.7.3", "dedent": "^0.6.0", "devtron": "1.3.0", - "etch": "^0.12.0", + "etch": "^0.12.2", "event-kit": "^2.3.0", "find-parent-dir": "^0.3.0", "first-mate": "7.0.4", diff --git a/spec/text-editor-element-spec.coffee b/spec/text-editor-element-spec.coffee index 867599c55f9..a64d1ac20ac 100644 --- a/spec/text-editor-element-spec.coffee +++ b/spec/text-editor-element-spec.coffee @@ -30,11 +30,12 @@ describe "TextEditorElement", -> expect(element.getModel().getText()).toBe 'testing' describe "when the model is assigned", -> - it "adds the 'mini' attribute if .isMini() returns true on the model", -> + it "adds the 'mini' attribute if .isMini() returns true on the model", (done) -> element = new TextEditorElement - model = new TextEditor({mini: true}) - element.setModel(model) - expect(element.hasAttribute('mini')).toBe true + element.getModel().update({mini: true}) + atom.views.getNextUpdatePromise().then -> + expect(element.hasAttribute('mini')).toBe true + done() describe "when the editor is attached to the DOM", -> it "mounts the component and unmounts when removed from the dom", -> @@ -42,12 +43,12 @@ describe "TextEditorElement", -> jasmine.attachToDOM(element) component = element.component - expect(component.mounted).toBe true + expect(component.attached).toBe true element.remove() - expect(component.mounted).toBe false + expect(component.attached).toBe false jasmine.attachToDOM(element) - expect(element.component.mounted).toBe true + expect(element.component.attached).toBe true describe "when the editor is detached from the DOM and then reattached", -> it "does not render duplicate line numbers", -> @@ -140,40 +141,6 @@ describe "TextEditorElement", -> jasmineContent.appendChild(parentElement) expect(document.activeElement).toBe element.querySelector('input') - describe "when the themes finish loading", -> - [themeReloadCallback, initialThemeLoadComplete, element] = [] - - beforeEach -> - themeReloadCallback = null - initialThemeLoadComplete = false - - spyOn(atom.themes, 'isInitialLoadComplete').andCallFake -> - initialThemeLoadComplete - spyOn(atom.themes, 'onDidChangeActiveThemes').andCallFake (fn) -> - themeReloadCallback = fn - new Disposable - - element = new TextEditorElement() - element.style.height = '200px' - element.getModel().update({autoHeight: false}) - element.getModel().setText [0..20].join("\n") - - it "re-renders the scrollbar", -> - jasmineContent.appendChild(element) - - atom.styles.addStyleSheet(""" - ::-webkit-scrollbar { - width: 8px; - } - """, context: 'atom-text-editor') - - initialThemeLoadComplete = true - themeReloadCallback() - - verticalScrollbarNode = element.querySelector(".vertical-scrollbar") - scrollbarWidth = verticalScrollbarNode.offsetWidth - verticalScrollbarNode.clientWidth - expect(scrollbarWidth).toEqual(8) - describe "::onDidAttach and ::onDidDetach", -> it "invokes callbacks when the element is attached and detached", -> element = new TextEditorElement @@ -236,19 +203,13 @@ describe "TextEditorElement", -> jasmine.attachToDOM(element) expect(element.getMaxScrollTop()).toBe(0) - - element.style.height = '100px' - editor.update({autoHeight: false}) - element.component.measureDimensions() - expect(element.getMaxScrollTop()).toBe(60) - - element.style.height = '120px' - element.component.measureDimensions() - expect(element.getMaxScrollTop()).toBe(40) - - element.style.height = '200px' - element.component.measureDimensions() - expect(element.getMaxScrollTop()).toBe(0) + waitsForPromise -> editor.update({autoHeight: false}) + runs -> element.style.height = '100px' + waitsFor -> element.getMaxScrollTop() is 60 + runs -> element.style.height = '120px' + waitsFor -> element.getMaxScrollTop() is 40 + runs -> element.style.height = '200px' + waitsFor -> element.getMaxScrollTop() is 0 describe "on TextEditor::setMini", -> it "changes the element's 'mini' attribute", -> @@ -256,9 +217,9 @@ describe "TextEditorElement", -> jasmine.attachToDOM(element) expect(element.hasAttribute('mini')).toBe false element.getModel().setMini(true) - expect(element.hasAttribute('mini')).toBe true - element.getModel().setMini(false) - expect(element.hasAttribute('mini')).toBe false + waitsFor -> element.hasAttribute('mini') + runs -> element.getModel().setMini(false) + waitsFor -> not element.hasAttribute('mini') describe "events", -> element = null diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 73fec8f7e8a..b601a4d357f 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -74,6 +74,7 @@ class TextEditorComponent { this.nextUpdateOnlyBlinksCursors = null this.horizontalPositionsToMeasure = new Map() // Keys are rows with positions we want to measure, values are arrays of columns to measure this.horizontalPixelPositionsByScreenLineId = new Map() // Values are maps from column to horiontal pixel positions + this.blockDecorationsToMeasure = new Set() this.lineNodesByScreenLineId = new Map() this.textNodesByScreenLineId = new Map() this.shouldRenderDummyScrollbars = true @@ -114,27 +115,6 @@ class TextEditorComponent { this.gutterContainerVnode = null this.cursorsVnode = null this.placeholderTextVnode = null - this.blockDecorationMeasurementAreaVnode = $.div({ - ref: 'blockDecorationMeasurementArea', - key: 'blockDecorationMeasurementArea', - style: { - contain: 'strict', - position: 'absolute', - visibility: 'hidden' - } - }) - this.characterMeasurementLineVnode = $.div( - { - key: 'characterMeasurementLine', - ref: 'characterMeasurementLine', - className: 'line dummy', - style: {position: 'absolute', visibility: 'hidden'} - }, - $.span({ref: 'normalWidthCharacterSpan'}, NORMAL_WIDTH_CHARACTER), - $.span({ref: 'doubleWidthCharacterSpan'}, DOUBLE_WIDTH_CHARACTER), - $.span({ref: 'halfWidthCharacterSpan'}, HALF_WIDTH_CHARACTER), - $.span({ref: 'koreanCharacterSpan'}, KOREAN_CHARACTER) - ) this.queryGuttersToRender() this.queryMaxLineNumberDigits() @@ -385,11 +365,7 @@ class TextEditorComponent { attributes, dataset, tabIndex: -1, - on: { - focus: this.didFocus, - blur: this.didBlur, - mousewheel: this.didMouseWheel - } + on: {mousewheel: this.didMouseWheel} }, $.div( { @@ -531,14 +507,14 @@ class TextEditorComponent { children = [ this.renderCursorsAndInput(), this.renderLineTiles(), - this.blockDecorationMeasurementAreaVnode, - this.characterMeasurementLineVnode, + this.renderBlockDecorationMeasurementArea(), + this.renderCharacterMeasurementLine(), this.renderPlaceholderText() ] } else { children = [ - this.blockDecorationMeasurementAreaVnode, - this.characterMeasurementLineVnode + this.renderBlockDecorationMeasurementArea(), + this.renderCharacterMeasurementLine() ] } @@ -667,6 +643,33 @@ class TextEditorComponent { return this.placeholderTextVnode } + renderCharacterMeasurementLine () { + return $.div( + { + key: 'characterMeasurementLine', + ref: 'characterMeasurementLine', + className: 'line dummy', + style: {position: 'absolute', visibility: 'hidden'} + }, + $.span({ref: 'normalWidthCharacterSpan'}, NORMAL_WIDTH_CHARACTER), + $.span({ref: 'doubleWidthCharacterSpan'}, DOUBLE_WIDTH_CHARACTER), + $.span({ref: 'halfWidthCharacterSpan'}, HALF_WIDTH_CHARACTER), + $.span({ref: 'koreanCharacterSpan'}, KOREAN_CHARACTER) + ) + } + + renderBlockDecorationMeasurementArea () { + return $.div({ + ref: 'blockDecorationMeasurementArea', + key: 'blockDecorationMeasurementArea', + style: { + contain: 'strict', + position: 'absolute', + visibility: 'hidden' + } + }) + } + renderHiddenInput () { let top, left if (this.hiddenInputPosition) { @@ -1205,6 +1208,8 @@ class TextEditorComponent { } } + // Called by TextEditorElement so that focus events can be handled before + // the element is attached to the DOM. didFocus () { // This element can be focused from a parent custom element's // attachedCallback before *its* attachedCallback is fired. This protects @@ -1243,6 +1248,9 @@ class TextEditorComponent { } } + // Called by TextEditorElement so that this function is always the first + // listener to be fired, even if other listeners are bound before creating + // the component. didBlur (event) { if (event.relatedTarget === this.refs.hiddenInput) { event.stopImmediatePropagation() @@ -2026,7 +2034,6 @@ class TextEditorComponent { this.disposables.add(model.onDidRemoveGutter(scheduleUpdate)) this.disposables.add(model.selectionsMarkerLayer.onDidUpdate(this.didUpdateSelections.bind(this))) this.disposables.add(model.onDidRequestAutoscroll(this.didRequestAutoscroll.bind(this))) - this.blockDecorationsToMeasure = new Set() this.disposables.add(model.observeDecorations((decoration) => { if (decoration.getProperties().type === 'block') this.observeBlockDecoration(decoration) })) @@ -2187,7 +2194,7 @@ class TextEditorComponent { } getGutterContainerWidth () { - return this.measurements.gutterContainerWidth + return (this.measurements) ? this.measurements.gutterContainerWidth : 0 } getLineNumberGutterWidth () { @@ -2262,6 +2269,7 @@ class TextEditorComponent { if (scrollTop !== this.scrollTop) { this.scrollTopPending = true this.scrollTop = scrollTop + this.element.emitter.emit('did-change-scroll-top', scrollTop) return true } else { return false @@ -2290,6 +2298,7 @@ class TextEditorComponent { if (scrollLeft !== this.scrollLeft) { this.scrollLeftPending = true this.scrollLeft = scrollLeft + this.element.emitter.emit('did-change-scroll-left', scrollLeft) return true } else { return false diff --git a/src/text-editor-element.js b/src/text-editor-element.js index 2a0f46496c5..9cf995ee93a 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -5,7 +5,6 @@ const dedent = require('dedent') class TextEditorElement extends HTMLElement { initialize (component) { this.component = component - this.emitter = new Emitter() return this } @@ -19,27 +18,85 @@ class TextEditorElement extends HTMLElement { return this } + createdCallback () { + this.emitter = new Emitter() + this.initialText = this.textContent + this.tabIndex = -1 + this.addEventListener('focus', (event) => this.getComponent().didFocus(event)) + this.addEventListener('blur', (event) => this.getComponent().didBlur(event)) + } + attachedCallback () { this.getComponent().didAttach() this.emitter.emit('did-attach') + this.updateModelFromAttributes() } detachedCallback () { + this.emitter.emit('did-detach') this.getComponent().didDetach() } + attributeChangedCallback (name, oldValue, newValue) { + if (this.component) { + switch (name) { + case 'mini': + this.getModel().update({mini: newValue != null}) + break; + case 'placeholder-text': + this.getModel().update({placeholderText: newValue}) + break; + case 'gutter-hidden': + this.getModel().update({isVisible: newValue != null}) + break; + } + } + } + getModel () { return this.getComponent().props.model } setModel (model) { - this.getComponent().setModel(model) + this.getComponent().update({model}) + this.updateModelFromAttributes() + } + + updateModelFromAttributes () { + const props = { + mini: this.hasAttribute('mini'), + placeholderText: this.getAttribute('placeholder-text'), + } + if (this.hasAttribute('gutter-hidden')) props.lineNumberGutterVisible = false + + this.getModel().update(props) + if (this.initialText) this.getModel().setText(this.initialText) } onDidAttach (callback) { return this.emitter.on('did-attach', callback) } + onDidDetach (callback) { + return this.emitter.on('did-detach', callback) + } + + setWidth (width) { + this.style.width = this.getComponent().getGutterContainerWidth() + width + 'px' + } + + getWidth () { + this.offsetWidth - this.getComponent().getGutterContainerWidth() + } + + setHeight (height) { + this.style.height = height + 'px' + } + + getHeight () { + return this.offsetHeight + } + onDidChangeScrollLeft (callback) { return this.emitter.on('did-change-scroll-left', callback) } @@ -52,14 +109,26 @@ class TextEditorElement extends HTMLElement { return this.getComponent().getBaseCharacterWidth() } + getMaxScrollTop () { + return this.getComponent().getMaxScrollTop() + } + getScrollTop () { return this.getComponent().getScrollTop() } + setScrollTop (scrollTop) { + this.getComponent().setScrollTop(scrollTop) + } + getScrollLeft () { return this.getComponent().getScrollLeft() } + setScrollLeft (scrollLeft) { + this.getComponent().setScrollLeft(scrollLeft) + } + hasFocus () { return this.getComponent().focused } @@ -81,6 +150,10 @@ class TextEditorElement extends HTMLElement { return updatedSynchronously } + isUpdatedSynchronously () { + return this.component ? this.component.updatedSynchronously : this.updatedSynchronously + } + // Experimental: Invalidate the passed block {Decoration}'s dimensions, // forcing them to be recalculated and the surrounding content to be adjusted // on the next animation frame. diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 7196e211857..ac0a05ca5d5 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -321,6 +321,7 @@ class TextEditor extends Model @cursorLineDecorations = null else @decorateCursorLine() + @component?.scheduleUpdate() when 'placeholderText' if value isnt @placeholderText From 174bac378d47435780747536d45faba1ce63cbe7 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 17 Apr 2017 09:15:15 -0600 Subject: [PATCH 243/403] Fix lint errors --- src/task-bootstrap.js | 4 +++- src/text-editor-component.js | 2 ++ src/text-editor-element.js | 9 +++++---- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/task-bootstrap.js b/src/task-bootstrap.js index 862b6d4acf3..2a70893ca52 100644 --- a/src/task-bootstrap.js +++ b/src/task-bootstrap.js @@ -1,3 +1,5 @@ +/* global snapshotResult */ + if (typeof snapshotResult !== 'undefined') { snapshotResult.setGlobals(global, process, global, {}, console, require) } @@ -6,7 +8,7 @@ const {userAgent} = process.env const [compileCachePath, taskPath] = process.argv.slice(2) const CompileCache = require('./compile-cache') -CompileCache.setCacheDirectory(compileCachePath); +CompileCache.setCacheDirectory(compileCachePath) CompileCache.install(`${process.resourcesPath}`, require) const setupGlobals = function () { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index b601a4d357f..14f4e2d528c 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1,3 +1,5 @@ +/* global ResizeObserver */ + const etch = require('etch') const {CompositeDisposable} = require('event-kit') const {Point, Range} = require('text-buffer') diff --git a/src/text-editor-element.js b/src/text-editor-element.js index 9cf995ee93a..35f0715ed32 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -1,4 +1,5 @@ const {Emitter} = require('atom') +const Grim = require('grim') const TextEditorComponent = require('./text-editor-component') const dedent = require('dedent') @@ -42,13 +43,13 @@ class TextEditorElement extends HTMLElement { switch (name) { case 'mini': this.getModel().update({mini: newValue != null}) - break; + break case 'placeholder-text': this.getModel().update({placeholderText: newValue}) - break; + break case 'gutter-hidden': this.getModel().update({isVisible: newValue != null}) - break; + break } } } @@ -65,7 +66,7 @@ class TextEditorElement extends HTMLElement { updateModelFromAttributes () { const props = { mini: this.hasAttribute('mini'), - placeholderText: this.getAttribute('placeholder-text'), + placeholderText: this.getAttribute('placeholder-text') } if (this.hasAttribute('gutter-hidden')) props.lineNumberGutterVisible = false From 0441625fba5660fa306c68f53a0883ffeee829b6 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 17 Apr 2017 09:17:46 -0600 Subject: [PATCH 244/403] Set lineHeightInPixels on model for backward compatibility --- src/text-editor-component.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 14f4e2d528c..f9d8bbacebf 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1771,6 +1771,7 @@ class TextEditorComponent { this.measurements.halfWidthCharacterWidth = this.refs.halfWidthCharacterSpan.getBoundingClientRect().width this.measurements.koreanCharacterWidth = this.refs.koreanCharacterSpan.getBoundingClientRect().width + this.props.model.setLineHeightInPixels(this.measurements.lineHeight) this.props.model.setDefaultCharWidth( this.measurements.baseCharacterWidth, this.measurements.doubleWidthCharacterWidth, From e423b833db32f7f3e52079434ec5ff81454a0f06 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 17 Apr 2017 09:45:05 -0600 Subject: [PATCH 245/403] Replace getDefaultCharacterWidth with getBaseCharacterWidth That's the language we use throughout the implementation now and a more accurate name for the concept. --- src/text-editor-element.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/text-editor-element.js b/src/text-editor-element.js index 35f0715ed32..ee193d5a63f 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -106,10 +106,19 @@ class TextEditorElement extends HTMLElement { return this.emitter.on('did-change-scroll-top', callback) } + // Deprecated: get the width of an `x` character displayed in this element. + // + // Returns a {Number} of pixels. getDefaultCharacterWidth () { return this.getComponent().getBaseCharacterWidth() } + // Extended: get the width of an `x` character displayed in this element. + // + // Returns a {Number} of pixels. + getBaseCharacterWidth () { + return this.getComponent().getBaseCharacterWidth() + } getMaxScrollTop () { return this.getComponent().getMaxScrollTop() } From 5000f9eccb4c4fbd00ec73416a899f0b6c30d533 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 17 Apr 2017 09:53:08 -0600 Subject: [PATCH 246/403] Convert text-editor-element-spec to JS --- spec/text-editor-element-spec.coffee | 281 ----------------------- spec/text-editor-element-spec.js | 324 +++++++++++++++++++++++++++ 2 files changed, 324 insertions(+), 281 deletions(-) delete mode 100644 spec/text-editor-element-spec.coffee create mode 100644 spec/text-editor-element-spec.js diff --git a/spec/text-editor-element-spec.coffee b/spec/text-editor-element-spec.coffee deleted file mode 100644 index a64d1ac20ac..00000000000 --- a/spec/text-editor-element-spec.coffee +++ /dev/null @@ -1,281 +0,0 @@ -TextEditor = require '../src/text-editor' -TextEditorElement = require '../src/text-editor-element' -{Disposable} = require 'event-kit' - -describe "TextEditorElement", -> - jasmineContent = null - - beforeEach -> - jasmineContent = document.body.querySelector('#jasmine-content') - - describe "instantiation", -> - it "honors the 'mini' attribute", -> - jasmineContent.innerHTML = "" - element = jasmineContent.firstChild - expect(element.getModel().isMini()).toBe true - - it "honors the 'placeholder-text' attribute", -> - jasmineContent.innerHTML = "" - element = jasmineContent.firstChild - expect(element.getModel().getPlaceholderText()).toBe 'testing' - - it "honors the 'gutter-hidden' attribute", -> - jasmineContent.innerHTML = "" - element = jasmineContent.firstChild - expect(element.getModel().isLineNumberGutterVisible()).toBe false - - it "honors the text content", -> - jasmineContent.innerHTML = "testing" - element = jasmineContent.firstChild - expect(element.getModel().getText()).toBe 'testing' - - describe "when the model is assigned", -> - it "adds the 'mini' attribute if .isMini() returns true on the model", (done) -> - element = new TextEditorElement - element.getModel().update({mini: true}) - atom.views.getNextUpdatePromise().then -> - expect(element.hasAttribute('mini')).toBe true - done() - - describe "when the editor is attached to the DOM", -> - it "mounts the component and unmounts when removed from the dom", -> - element = new TextEditorElement - jasmine.attachToDOM(element) - - component = element.component - expect(component.attached).toBe true - element.remove() - expect(component.attached).toBe false - - jasmine.attachToDOM(element) - expect(element.component.attached).toBe true - - describe "when the editor is detached from the DOM and then reattached", -> - it "does not render duplicate line numbers", -> - editor = new TextEditor - editor.setText('1\n2\n3') - element = editor.getElement() - - jasmine.attachToDOM(element) - - initialCount = element.querySelectorAll('.line-number').length - - element.remove() - jasmine.attachToDOM(element) - expect(element.querySelectorAll('.line-number').length).toBe initialCount - - it "does not render duplicate decorations in custom gutters", -> - editor = new TextEditor - editor.setText('1\n2\n3') - editor.addGutter({name: 'test-gutter'}) - marker = editor.markBufferRange([[0, 0], [2, 0]]) - editor.decorateMarker(marker, {type: 'gutter', gutterName: 'test-gutter'}) - element = editor.getElement() - - jasmine.attachToDOM(element) - initialDecorationCount = element.querySelectorAll('.decoration').length - - element.remove() - jasmine.attachToDOM(element) - expect(element.querySelectorAll('.decoration').length).toBe initialDecorationCount - - it "can be re-focused using the previous `document.activeElement`", -> - editorElement = document.createElement('atom-text-editor') - jasmine.attachToDOM(editorElement) - editorElement.focus() - - activeElement = document.activeElement - - editorElement.remove() - jasmine.attachToDOM(editorElement) - activeElement.focus() - - expect(editorElement.hasFocus()).toBe(true) - - describe "focus and blur handling", -> - it "proxies focus/blur events to/from the hidden input", -> - element = new TextEditorElement - jasmineContent.appendChild(element) - - blurCalled = false - element.addEventListener 'blur', -> blurCalled = true - - element.focus() - expect(blurCalled).toBe false - expect(element.hasFocus()).toBe true - expect(document.activeElement).toBe element.querySelector('input') - - document.body.focus() - expect(blurCalled).toBe true - - it "doesn't trigger a blur event on the editor element when focusing an already focused editor element", -> - blurCalled = false - element = new TextEditorElement - element.addEventListener 'blur', -> blurCalled = true - - jasmineContent.appendChild(element) - expect(document.activeElement).toBe(document.body) - expect(blurCalled).toBe(false) - - element.focus() - expect(document.activeElement).toBe(element.querySelector('input')) - expect(blurCalled).toBe(false) - - element.focus() - expect(document.activeElement).toBe(element.querySelector('input')) - expect(blurCalled).toBe(false) - - describe "when focused while a parent node is being attached to the DOM", -> - class ElementThatFocusesChild extends HTMLDivElement - attachedCallback: -> - @firstChild.focus() - - document.registerElement("element-that-focuses-child", - prototype: ElementThatFocusesChild.prototype - ) - - it "proxies the focus event to the hidden input", -> - element = new TextEditorElement - parentElement = document.createElement("element-that-focuses-child") - parentElement.appendChild(element) - jasmineContent.appendChild(parentElement) - expect(document.activeElement).toBe element.querySelector('input') - - describe "::onDidAttach and ::onDidDetach", -> - it "invokes callbacks when the element is attached and detached", -> - element = new TextEditorElement - - attachedCallback = jasmine.createSpy("attachedCallback") - detachedCallback = jasmine.createSpy("detachedCallback") - - element.onDidAttach(attachedCallback) - element.onDidDetach(detachedCallback) - - jasmine.attachToDOM(element) - - expect(attachedCallback).toHaveBeenCalled() - expect(detachedCallback).not.toHaveBeenCalled() - - attachedCallback.reset() - element.remove() - - expect(attachedCallback).not.toHaveBeenCalled() - expect(detachedCallback).toHaveBeenCalled() - - describe "::setUpdatedSynchronously", -> - it "controls whether the text editor is updated synchronously", -> - spyOn(window, 'requestAnimationFrame').andCallFake (fn) -> fn() - - element = new TextEditorElement - jasmine.attachToDOM(element) - - element.setUpdatedSynchronously(false) - expect(element.isUpdatedSynchronously()).toBe false - - element.getModel().setText("hello") - expect(window.requestAnimationFrame).toHaveBeenCalled() - - expect(element.textContent).toContain "hello" - - window.requestAnimationFrame.reset() - element.setUpdatedSynchronously(true) - element.getModel().setText("goodbye") - expect(window.requestAnimationFrame).not.toHaveBeenCalled() - expect(element.textContent).toContain "goodbye" - - describe "::getDefaultCharacterWidth", -> - it "returns null before the element is attached", -> - element = new TextEditorElement - expect(element.getDefaultCharacterWidth()).toBeNull() - - it "returns the width of a character in the root scope", -> - element = new TextEditorElement - jasmine.attachToDOM(element) - expect(element.getDefaultCharacterWidth()).toBeGreaterThan(0) - - describe "::getMaxScrollTop", -> - it "returns the maximum scroll top that can be applied to the element", -> - editor = new TextEditor - editor.setText('1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16') - element = editor.getElement() - element.style.lineHeight = "10px" - element.style.width = "200px" - jasmine.attachToDOM(element) - - expect(element.getMaxScrollTop()).toBe(0) - waitsForPromise -> editor.update({autoHeight: false}) - runs -> element.style.height = '100px' - waitsFor -> element.getMaxScrollTop() is 60 - runs -> element.style.height = '120px' - waitsFor -> element.getMaxScrollTop() is 40 - runs -> element.style.height = '200px' - waitsFor -> element.getMaxScrollTop() is 0 - - describe "on TextEditor::setMini", -> - it "changes the element's 'mini' attribute", -> - element = new TextEditorElement - jasmine.attachToDOM(element) - expect(element.hasAttribute('mini')).toBe false - element.getModel().setMini(true) - waitsFor -> element.hasAttribute('mini') - runs -> element.getModel().setMini(false) - waitsFor -> not element.hasAttribute('mini') - - describe "events", -> - element = null - - beforeEach -> - element = new TextEditorElement - element.getModel().setText("lorem\nipsum\ndolor\nsit\namet") - element.setUpdatedSynchronously(true) - element.setHeight(20) - element.setWidth(20) - element.getModel().update({autoHeight: false}) - - describe "::onDidChangeScrollTop(callback)", -> - it "triggers even when subscribing before attaching the element", -> - positions = [] - subscription1 = element.onDidChangeScrollTop (p) -> positions.push(p) - jasmine.attachToDOM(element) - subscription2 = element.onDidChangeScrollTop (p) -> positions.push(p) - - positions.length = 0 - element.setScrollTop(10) - expect(positions).toEqual([10, 10]) - - element.remove() - jasmine.attachToDOM(element) - - positions.length = 0 - element.setScrollTop(20) - expect(positions).toEqual([20, 20]) - - subscription1.dispose() - - positions.length = 0 - element.setScrollTop(30) - expect(positions).toEqual([30]) - - describe "::onDidChangeScrollLeft(callback)", -> - it "triggers even when subscribing before attaching the element", -> - positions = [] - subscription1 = element.onDidChangeScrollLeft (p) -> positions.push(p) - jasmine.attachToDOM(element) - subscription2 = element.onDidChangeScrollLeft (p) -> positions.push(p) - - positions.length = 0 - element.setScrollLeft(10) - expect(positions).toEqual([10, 10]) - - element.remove() - jasmine.attachToDOM(element) - - positions.length = 0 - element.setScrollLeft(20) - expect(positions).toEqual([20, 20]) - - subscription1.dispose() - - positions.length = 0 - element.setScrollLeft(30) - expect(positions).toEqual([30]) diff --git a/spec/text-editor-element-spec.js b/spec/text-editor-element-spec.js new file mode 100644 index 00000000000..7d1d1d17599 --- /dev/null +++ b/spec/text-editor-element-spec.js @@ -0,0 +1,324 @@ +/* global HTMLDivElement */ + +const TextEditor = require('../src/text-editor') +const TextEditorElement = require('../src/text-editor-element') + +describe('TextEditorElement', () => { + let jasmineContent + + beforeEach(() => { + jasmineContent = document.body.querySelector('#jasmine-content') + }) + + describe('instantiation', () => { + it("honors the 'mini' attribute", () => { + jasmineContent.innerHTML = '' + const element = jasmineContent.firstChild + expect(element.getModel().isMini()).toBe(true) + }) + + it("honors the 'placeholder-text' attribute", () => { + jasmineContent.innerHTML = "" + const element = jasmineContent.firstChild + expect(element.getModel().getPlaceholderText()).toBe('testing') + }) + + it("honors the 'gutter-hidden' attribute", () => { + jasmineContent.innerHTML = '' + const element = jasmineContent.firstChild + expect(element.getModel().isLineNumberGutterVisible()).toBe(false) + }) + + it('honors the text content', () => { + jasmineContent.innerHTML = 'testing' + const element = jasmineContent.firstChild + expect(element.getModel().getText()).toBe('testing') + }) + }) + + describe('when the model is assigned', () => + it("adds the 'mini' attribute if .isMini() returns true on the model", function (done) { + const element = new TextEditorElement() + element.getModel().update({mini: true}) + atom.views.getNextUpdatePromise().then(() => { + expect(element.hasAttribute('mini')).toBe(true) + done() + }) + }) + ) + + describe('when the editor is attached to the DOM', () => + it('mounts the component and unmounts when removed from the dom', () => { + const element = new TextEditorElement() + jasmine.attachToDOM(element) + + const { component } = element + expect(component.attached).toBe(true) + element.remove() + expect(component.attached).toBe(false) + + jasmine.attachToDOM(element) + expect(element.component.attached).toBe(true) + }) + ) + + describe('when the editor is detached from the DOM and then reattached', () => { + it('does not render duplicate line numbers', () => { + const editor = new TextEditor() + editor.setText('1\n2\n3') + const element = editor.getElement() + + jasmine.attachToDOM(element) + + const initialCount = element.querySelectorAll('.line-number').length + + element.remove() + jasmine.attachToDOM(element) + expect(element.querySelectorAll('.line-number').length).toBe(initialCount) + }) + + it('does not render duplicate decorations in custom gutters', () => { + const editor = new TextEditor() + editor.setText('1\n2\n3') + editor.addGutter({name: 'test-gutter'}) + const marker = editor.markBufferRange([[0, 0], [2, 0]]) + editor.decorateMarker(marker, {type: 'gutter', gutterName: 'test-gutter'}) + const element = editor.getElement() + + jasmine.attachToDOM(element) + const initialDecorationCount = element.querySelectorAll('.decoration').length + + element.remove() + jasmine.attachToDOM(element) + expect(element.querySelectorAll('.decoration').length).toBe(initialDecorationCount) + }) + + it('can be re-focused using the previous `document.activeElement`', () => { + const editorElement = document.createElement('atom-text-editor') + jasmine.attachToDOM(editorElement) + editorElement.focus() + + const { activeElement } = document + + editorElement.remove() + jasmine.attachToDOM(editorElement) + activeElement.focus() + + expect(editorElement.hasFocus()).toBe(true) + }) + }) + + describe('focus and blur handling', () => { + it('proxies focus/blur events to/from the hidden input', () => { + const element = new TextEditorElement() + jasmineContent.appendChild(element) + + let blurCalled = false + element.addEventListener('blur', () => { + blurCalled = true + }) + + element.focus() + expect(blurCalled).toBe(false) + expect(element.hasFocus()).toBe(true) + expect(document.activeElement).toBe(element.querySelector('input')) + + document.body.focus() + expect(blurCalled).toBe(true) + }) + + it("doesn't trigger a blur event on the editor element when focusing an already focused editor element", () => { + let blurCalled = false + const element = new TextEditorElement() + element.addEventListener('blur', () => { blurCalled = true }) + + jasmineContent.appendChild(element) + expect(document.activeElement).toBe(document.body) + expect(blurCalled).toBe(false) + + element.focus() + expect(document.activeElement).toBe(element.querySelector('input')) + expect(blurCalled).toBe(false) + + element.focus() + expect(document.activeElement).toBe(element.querySelector('input')) + expect(blurCalled).toBe(false) + }) + + describe('when focused while a parent node is being attached to the DOM', () => { + class ElementThatFocusesChild extends HTMLDivElement { + attachedCallback () { + this.firstChild.focus() + } + } + + document.registerElement('element-that-focuses-child', + {prototype: ElementThatFocusesChild.prototype} + ) + + it('proxies the focus event to the hidden input', () => { + const element = new TextEditorElement() + const parentElement = document.createElement('element-that-focuses-child') + parentElement.appendChild(element) + jasmineContent.appendChild(parentElement) + expect(document.activeElement).toBe(element.querySelector('input')) + }) + }) + }) + + describe('::onDidAttach and ::onDidDetach', () => + it('invokes callbacks when the element is attached and detached', () => { + const element = new TextEditorElement() + + const attachedCallback = jasmine.createSpy('attachedCallback') + const detachedCallback = jasmine.createSpy('detachedCallback') + + element.onDidAttach(attachedCallback) + element.onDidDetach(detachedCallback) + + jasmine.attachToDOM(element) + + expect(attachedCallback).toHaveBeenCalled() + expect(detachedCallback).not.toHaveBeenCalled() + + attachedCallback.reset() + element.remove() + + expect(attachedCallback).not.toHaveBeenCalled() + expect(detachedCallback).toHaveBeenCalled() + }) + ) + + describe('::setUpdatedSynchronously', () => + it('controls whether the text editor is updated synchronously', () => { + spyOn(window, 'requestAnimationFrame').andCallFake(fn => fn()) + + const element = new TextEditorElement() + jasmine.attachToDOM(element) + + element.setUpdatedSynchronously(false) + expect(element.isUpdatedSynchronously()).toBe(false) + + element.getModel().setText('hello') + expect(window.requestAnimationFrame).toHaveBeenCalled() + + expect(element.textContent).toContain('hello') + + window.requestAnimationFrame.reset() + element.setUpdatedSynchronously(true) + element.getModel().setText('goodbye') + expect(window.requestAnimationFrame).not.toHaveBeenCalled() + expect(element.textContent).toContain('goodbye') + }) + ) + + describe('::getDefaultCharacterWidth', () => { + it('returns null before the element is attached', () => { + const element = new TextEditorElement() + expect(element.getDefaultCharacterWidth()).toBeNull() + }) + + it('returns the width of a character in the root scope', () => { + const element = new TextEditorElement() + jasmine.attachToDOM(element) + expect(element.getDefaultCharacterWidth()).toBeGreaterThan(0) + }) + }) + + describe('::getMaxScrollTop', () => + it('returns the maximum scroll top that can be applied to the element', () => { + const editor = new TextEditor() + editor.setText('1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16') + const element = editor.getElement() + element.style.lineHeight = '10px' + element.style.width = '200px' + jasmine.attachToDOM(element) + + expect(element.getMaxScrollTop()).toBe(0) + waitsForPromise(() => editor.update({autoHeight: false})) + runs(() => { element.style.height = '100px' }) + waitsFor(() => element.getMaxScrollTop() === 60) + runs(() => { element.style.height = '120px' }) + waitsFor(() => element.getMaxScrollTop() === 40) + runs(() => { element.style.height = '200px' }) + waitsFor(() => element.getMaxScrollTop() === 0) + }) + ) + + describe('on TextEditor::setMini', () => + it("changes the element's 'mini' attribute", () => { + const element = new TextEditorElement() + jasmine.attachToDOM(element) + expect(element.hasAttribute('mini')).toBe(false) + element.getModel().setMini(true) + waitsFor(() => element.hasAttribute('mini')) + runs(() => element.getModel().setMini(false)) + waitsFor(() => !element.hasAttribute('mini')) + }) + ) + + describe('events', () => { + let element = null + + beforeEach(() => { + element = new TextEditorElement() + element.getModel().setText('lorem\nipsum\ndolor\nsit\namet') + element.setUpdatedSynchronously(true) + element.setHeight(20) + element.setWidth(20) + element.getModel().update({autoHeight: false}) + }) + + describe('::onDidChangeScrollTop(callback)', () => + it('triggers even when subscribing before attaching the element', () => { + const positions = [] + const subscription1 = element.onDidChangeScrollTop(p => positions.push(p)) + jasmine.attachToDOM(element) + element.onDidChangeScrollTop(p => positions.push(p)) + + positions.length = 0 + element.setScrollTop(10) + expect(positions).toEqual([10, 10]) + + element.remove() + jasmine.attachToDOM(element) + + positions.length = 0 + element.setScrollTop(20) + expect(positions).toEqual([20, 20]) + + subscription1.dispose() + + positions.length = 0 + element.setScrollTop(30) + expect(positions).toEqual([30]) + }) + ) + + describe('::onDidChangeScrollLeft(callback)', () => + it('triggers even when subscribing before attaching the element', () => { + const positions = [] + const subscription1 = element.onDidChangeScrollLeft(p => positions.push(p)) + jasmine.attachToDOM(element) + element.onDidChangeScrollLeft(p => positions.push(p)) + + positions.length = 0 + element.setScrollLeft(10) + expect(positions).toEqual([10, 10]) + + element.remove() + jasmine.attachToDOM(element) + + positions.length = 0 + element.setScrollLeft(20) + expect(positions).toEqual([20, 20]) + + subscription1.dispose() + + positions.length = 0 + element.setScrollLeft(30) + expect(positions).toEqual([30]) + }) + ) + }) +}) From a9d0f82afb759c26c3266476a895f4c37c404cf3 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 17 Apr 2017 10:11:20 -0600 Subject: [PATCH 247/403] Use async/await in text-editor-element-spec --- spec/text-editor-element-spec.js | 33 ++++++++++++++++++++------------ src/text-editor-element.js | 11 +++++++++++ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/spec/text-editor-element-spec.js b/spec/text-editor-element-spec.js index 7d1d1d17599..ee1ef37cd50 100644 --- a/spec/text-editor-element-spec.js +++ b/spec/text-editor-element-spec.js @@ -1,5 +1,6 @@ /* global HTMLDivElement */ +const {it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise, timeoutPromise} = require('./async-spec-helpers') const TextEditor = require('../src/text-editor') const TextEditorElement = require('../src/text-editor-element') @@ -226,7 +227,7 @@ describe('TextEditorElement', () => { }) describe('::getMaxScrollTop', () => - it('returns the maximum scroll top that can be applied to the element', () => { + it('returns the maximum scroll top that can be applied to the element', async () => { const editor = new TextEditor() editor.setText('1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16') const element = editor.getElement() @@ -235,25 +236,33 @@ describe('TextEditorElement', () => { jasmine.attachToDOM(element) expect(element.getMaxScrollTop()).toBe(0) - waitsForPromise(() => editor.update({autoHeight: false})) - runs(() => { element.style.height = '100px' }) - waitsFor(() => element.getMaxScrollTop() === 60) - runs(() => { element.style.height = '120px' }) - waitsFor(() => element.getMaxScrollTop() === 40) - runs(() => { element.style.height = '200px' }) - waitsFor(() => element.getMaxScrollTop() === 0) + await editor.update({autoHeight: false}) + + element.style.height = '100px' + await element.getNextUpdatePromise() + expect(element.getMaxScrollTop()).toBe(60) + + element.style.height = '120px' + await element.getNextUpdatePromise() + expect(element.getMaxScrollTop()).toBe(40) + + element.style.height = '200px' + await element.getNextUpdatePromise() + expect(element.getMaxScrollTop()).toBe(0) }) ) describe('on TextEditor::setMini', () => - it("changes the element's 'mini' attribute", () => { + it("changes the element's 'mini' attribute", async () => { const element = new TextEditorElement() jasmine.attachToDOM(element) expect(element.hasAttribute('mini')).toBe(false) element.getModel().setMini(true) - waitsFor(() => element.hasAttribute('mini')) - runs(() => element.getModel().setMini(false)) - waitsFor(() => !element.hasAttribute('mini')) + await element.getNextUpdatePromise() + expect(element.hasAttribute('mini')).toBe(true) + element.getModel().setMini(false) + await element.getNextUpdatePromise() + expect(element.hasAttribute('mini')).toBe(false) }) ) diff --git a/src/text-editor-element.js b/src/text-editor-element.js index ee193d5a63f..442d7e021a4 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -54,6 +54,17 @@ class TextEditorElement extends HTMLElement { } } + // Extended: Get a promise that resolves the next time the element's DOM + // is updated in any way. + // + // This can be useful when you've made a change to the model and need to + // be sure this change has been flushed to the DOM. + // + // Returns a {Promise}. + getNextUpdatePromise () { + return this.getComponent().getNextUpdatePromise() + } + getModel () { return this.getComponent().props.model } From a536c5950a64c7120df73223af43d2a950826f41 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 17 Apr 2017 13:29:59 -0600 Subject: [PATCH 248/403] Add TextEditorElement.pixelPositionForScreen/BufferPosition These methods require us to render off-screen lines in some circumstances in order to measure them, so this commit extends the rendering of the longest line to include arbitrary lines. --- spec/text-editor-component-spec.js | 30 +++++++++++++ src/text-editor-component.js | 68 +++++++++++++++++++++++------- src/text-editor-element.js | 31 ++++++++++++++ 3 files changed, 114 insertions(+), 15 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index b77fedb4553..ac8b344924a 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -2365,6 +2365,36 @@ describe('TextEditorComponent', () => { await component.getNextUpdatePromise() }) }) + + describe('pixelPositionForScreenPositionSync(point)', () => { + it('returns the pixel position for the given point, regardless of whether or not it is currently on screen', async () => { + const {component, element, editor} = buildComponent({rowsPerTile: 2, autoHeight: false}) + await setEditorHeightInLines(component, 3) + await setScrollTop(component, 3 * component.getLineHeight()) + + const {component: referenceComponent} = buildComponent() + const referenceContentRect = referenceComponent.refs.content.getBoundingClientRect() + + { + const {top, left} = component.pixelPositionForScreenPositionSync({row: 0, column: 0}) + expect(top).toBe(clientTopForLine(referenceComponent, 0) - referenceContentRect.top) + expect(left).toBe(clientLeftForCharacter(referenceComponent, 0, 0) - referenceContentRect.left) + } + + { + const {top, left} = component.pixelPositionForScreenPositionSync({row: 0, column: 5}) + expect(top).toBe(clientTopForLine(referenceComponent, 0) - referenceContentRect.top) + expect(left).toBe(clientLeftForCharacter(referenceComponent, 0, 5) - referenceContentRect.left) + } + + { + const {top, left} = component.pixelPositionForScreenPositionSync({row: 12, column: 1}) + expect(top).toBe(clientTopForLine(referenceComponent, 12) - referenceContentRect.top) + expect(left).toBe(clientLeftForCharacter(referenceComponent, 12, 1) - referenceContentRect.left) + } + }) + }) + }) function buildEditor (params = {}) { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index f9d8bbacebf..d1327a09e05 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -74,6 +74,8 @@ class TextEditorComponent { this.cursorsBlinking = false this.cursorsBlinkedOff = false this.nextUpdateOnlyBlinksCursors = null + this.extraLinesToMeasure = null + this.extraRenderedScreenLines = null this.horizontalPositionsToMeasure = new Map() // Keys are rows with positions we want to measure, values are arrays of columns to measure this.horizontalPixelPositionsByScreenLineId = new Map() // Values are maps from column to horiontal pixel positions this.blockDecorationsToMeasure = new Set() @@ -131,6 +133,17 @@ class TextEditorComponent { this.scheduleUpdate() } + pixelPositionForScreenPositionSync ({row, column}) { + const top = this.pixelPositionAfterBlocksForRow(row) + let left = column === 0 ? 0 : this.pixelLeftForRowAndColumn(row, column) + if (left == null) { + this.requestHorizontalMeasurement(row, column) + this.updateSync() + left = this.pixelLeftForRowAndColumn(row, column) + } + return {top, left} + } + scheduleUpdate (nextUpdateOnlyBlinksCursors = false) { if (!this.visible) return @@ -262,7 +275,6 @@ class TextEditorComponent { } updateSyncBeforeMeasuringContent () { - this.horizontalPositionsToMeasure.clear() if (this.pendingAutoscroll) this.autoscrollVertically() this.populateVisibleRowRange() this.queryScreenLinesToRender() @@ -276,8 +288,6 @@ class TextEditorComponent { } measureContentDuringUpdateSync () { - this.measureHorizontalPositions() - this.updateAbsolutePositionedDecorations() if (this.remeasureGutterDimensions) { if (this.measureGutterDimensions()) { this.gutterContainerVnode = null @@ -285,7 +295,13 @@ class TextEditorComponent { this.remeasureGutterDimensions = false } const wasHorizontalScrollbarVisible = this.isHorizontalScrollbarVisible() + + this.extraRenderedScreenLines = this.extraLinesToMeasure + this.extraLinesToMeasure = null this.measureLongestLineWidth() + this.measureHorizontalPositions() + this.updateAbsolutePositionedDecorations() + if (this.pendingAutoscroll) { this.autoscrollHorizontally() if (!wasHorizontalScrollbarVisible && this.isHorizontalScrollbarVisible()) { @@ -566,14 +582,18 @@ class TextEditorComponent { }) } - if (this.longestLineToMeasure != null && (this.longestLineToMeasureRow < startRow || this.longestLineToMeasureRow >= endRow)) { - tileNodes.push($(LineComponent, { - key: this.longestLineToMeasure.id, - screenLine: this.longestLineToMeasure, - displayLayer, - lineNodesByScreenLineId, - textNodesByScreenLineId - })) + if (this.extraLinesToMeasure) { + this.extraLinesToMeasure.forEach((screenLine, row) => { + if (row < startRow || row >= endRow) { + tileNodes.push($(LineComponent, { + key: 'extra-' + screenLine.id, + screenLine, + displayLayer, + lineNodesByScreenLineId, + textNodesByScreenLineId + })) + } + }) } return $.div({ @@ -805,8 +825,8 @@ class TextEditorComponent { const longestLineRow = model.getApproximateLongestScreenRow() const longestLine = model.screenLineForScreenRow(longestLineRow) if (longestLine !== this.previousLongestLine) { + this.requestExtraLineToMeasure(longestLineRow, longestLine) this.longestLineToMeasure = longestLine - this.longestLineToMeasureRow = longestLineRow this.previousLongestLine = longestLine } } @@ -857,7 +877,10 @@ class TextEditorComponent { } renderedScreenLineForRow (row) { - return this.renderedScreenLines[row - this.getRenderedStartRow()] + return ( + this.renderedScreenLines[row - this.getRenderedStartRow()] || + (this.extraRenderedScreenLines ? this.extraRenderedScreenLines.get(row) : null) + ) } queryGuttersToRender () { @@ -1845,13 +1868,22 @@ class TextEditorComponent { measureLongestLineWidth () { if (this.longestLineToMeasure) { this.measurements.longestLineWidth = this.lineNodesByScreenLineId.get(this.longestLineToMeasure.id).firstChild.offsetWidth - this.longestLineToMeasureRow = null this.longestLineToMeasure = null } } + requestExtraLineToMeasure (row, screenLine) { + if (!this.extraLinesToMeasure) this.extraLinesToMeasure = new Map() + this.extraLinesToMeasure.set(row, screenLine) + } + requestHorizontalMeasurement (row, column) { if (column === 0) return + + if (row < this.getRenderedStartRow() || row >= this.getRenderedEndRow()) { + this.requestExtraLineToMeasure(row, this.props.model.screenLineForScreenRow(row)) + } + let columns = this.horizontalPositionsToMeasure.get(row) if (columns == null) { columns = [] @@ -1882,6 +1914,7 @@ class TextEditorComponent { this.measureHorizontalPositionsOnLine(lineNode, textNodes, columnsToMeasure, positionsForLine) }) + this.horizontalPositionsToMeasure.clear() } measureHorizontalPositionsOnLine (lineNode, textNodes, columnsToMeasure, positions) { @@ -1937,7 +1970,12 @@ class TextEditorComponent { pixelLeftForRowAndColumn (row, column) { if (column === 0) return 0 const screenLine = this.renderedScreenLineForRow(row) - return this.horizontalPixelPositionsByScreenLineId.get(screenLine.id).get(column) + if (screenLine) { + const horizontalPositionsByColumn = this.horizontalPixelPositionsByScreenLineId.get(screenLine.id) + if (horizontalPositionsByColumn) { + return horizontalPositionsByColumn.get(column) + } + } } screenPositionForPixelPosition ({top, left}) { diff --git a/src/text-editor-element.js b/src/text-editor-element.js index 442d7e021a4..fa387527c41 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -154,6 +154,37 @@ class TextEditorElement extends HTMLElement { return this.getComponent().focused } + // Extended: Converts a buffer position to a pixel position. + // + // * `bufferPosition` A {Point}-like object that represents a buffer position. + // + // Be aware that calling this method with a column that does not translate + // to column 0 on screen could cause a synchronous DOM update in order to + // measure the requested horizontal pixel position if it isn't already + // cached. + // + // Returns an {Object} with two values: `top` and `left`, representing the + // pixel position. + pixelPositionForBufferPosition (bufferPosition) { + const screenPosition = this.getModel().screenPositionForBufferPosition(bufferPosition) + return this.getComponent().pixelPositionForScreenPositionSync(screenPosition) + } + + // Extended: Converts a screen position to a pixel position. + // + // * `screenPosition` A {Point}-like object that represents a buffer position. + // + // Be aware that calling this method with a non-zero column value could + // cause a synchronous DOM update in order to measure the requested + // horizontal pixel position if it isn't already cached. + // + // Returns an {Object} with two values: `top` and `left`, representing the + // pixel position. + pixelPositionForScreenPosition (screenPosition) { + screenPosition = this.getModel().clipScreenPosition(screenPosition) + return this.getComponent().pixelPositionForScreenPositionSync(screenPosition) + } + getComponent () { if (!this.component) { this.component = new TextEditorComponent({ From 84c20d95d402cdb9aa9953d50dea863bb1d357a8 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 17 Apr 2017 13:57:53 -0600 Subject: [PATCH 249/403] Add deprecated rootElement property --- src/text-editor-element.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/text-editor-element.js b/src/text-editor-element.js index fa387527c41..fe780f7db86 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -19,6 +19,16 @@ class TextEditorElement extends HTMLElement { return this } + get rootElement () { + Grim.deprecate(dedent` + The contents of \`atom-text-editor\` elements are no longer encapsulated + within a shadow DOM boundary. Please, stop using \`rootElement\` and access + the editor contents directly instead. + `) + + return this + } + createdCallback () { this.emitter = new Emitter() this.initialText = this.textContent From c76fc5af2d47d1ccfe682f7d3bb8cf8c18a52e80 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 17 Apr 2017 13:58:38 -0600 Subject: [PATCH 250/403] Round column measurements to nearest whole pixel This preserves the expected behavior for positioning overlays, etc so that package tests keep passing. --- src/text-editor-component.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index d1327a09e05..0385b325362 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1945,7 +1945,7 @@ class TextEditorComponent { clientPixelPosition = clientRectForRange(textNode, 0, nextColumnToMeasure - textNodeStartColumn).right } if (lineNodeClientLeft === -1) lineNodeClientLeft = lineNode.getBoundingClientRect().left - positions.set(nextColumnToMeasure, clientPixelPosition - lineNodeClientLeft) + positions.set(nextColumnToMeasure, Math.round(clientPixelPosition - lineNodeClientLeft)) continue columnLoop // eslint-disable-line no-labels } else { textNodesIndex++ From 5b073349938308a205b0e924e2dac725245f9a9a Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 17 Apr 2017 15:40:39 -0600 Subject: [PATCH 251/403] Assign bufferRow property to line number nodes I wish we didn't need this, but it's currently relied on by several packages including bookmarks. --- spec/text-editor-component-spec.js | 15 ++++++++++++ src/text-editor-component.js | 37 +++++++++++++++++------------- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index ac8b344924a..ad3b7dd2092 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -485,6 +485,21 @@ describe('TextEditorComponent', () => { await component.getNextUpdatePromise() expect(element.classList.contains('has-selection')).toBe(false) }) + + it('assigns a buffer-row to each line number as a data field', async () => { + const {editor, element, component} = buildComponent() + editor.setSoftWrapped(true) + await component.getNextUpdatePromise() + await setEditorWidthInCharacters(component, 40) + + expect( + Array.from(element.querySelectorAll('.line-number:not(.dummy)')) + .map((element) => element.dataset.bufferRow) + ).toEqual([ + '0', '1', '2', '3', '3', '4', '5', '6', '6', '6', + '7', '8', '8', '8', '9', '10', '11', '11', '12' + ]) + }) }) describe('mini editors', () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 0385b325362..dd4d2f6400d 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -97,8 +97,9 @@ class TextEditorComponent { this.guttersToRender = [this.props.model.getLineNumberGutter()] this.lineNumbersToRender = { maxDigits: 2, - numbers: [], + bufferRows: [], keys: [], + softWrappedFlags: [], foldableFlags: [] } this.decorationsToRender = { @@ -456,7 +457,7 @@ class TextEditorComponent { if (!this.props.model.isLineNumberGutterVisible()) return null if (this.measurements) { - const {maxDigits, keys, numbers, foldableFlags} = this.lineNumbersToRender + const {maxDigits, keys, bufferRows, softWrappedFlags, foldableFlags} = this.lineNumbersToRender return $(LineNumberGutterComponent, { ref: 'lineNumberGutter', element: gutter.getElement(), @@ -466,7 +467,8 @@ class TextEditorComponent { rowsPerTile: this.getRowsPerTile(), maxDigits: maxDigits, keys: keys, - numbers: numbers, + bufferRows: bufferRows, + softWrappedFlags: softWrappedFlags, foldableFlags: foldableFlags, decorations: this.decorationsToRender.lineNumbers, blockDecorations: this.decorationsToRender.blocks, @@ -841,8 +843,8 @@ class TextEditorComponent { const endRow = this.getRenderedEndRow() const renderedRowCount = this.getRenderedRowCount() - const {numbers, keys, foldableFlags} = this.lineNumbersToRender - numbers.length = renderedRowCount + const {bufferRows, keys, softWrappedFlags, foldableFlags} = this.lineNumbersToRender + bufferRows.length = renderedRowCount keys.length = renderedRowCount foldableFlags.length = renderedRowCount @@ -851,15 +853,17 @@ class TextEditorComponent { for (let row = startRow; row < endRow; row++) { const i = row - startRow const bufferRow = model.bufferRowForScreenRow(row) + bufferRows[i] = bufferRow if (bufferRow === previousBufferRow) { - numbers[i] = -1 - keys[i] = bufferRow + 1 + '-' + softWrapCount++ + softWrapCount++ + softWrappedFlags[i] = true foldableFlags[i] = false + keys[i] = bufferRow + '-' + softWrapCount } else { softWrapCount = 0 - numbers[i] = bufferRow + 1 - keys[i] = bufferRow + 1 + softWrappedFlags[i] = false foldableFlags[i] = model.isFoldableAtBufferRow(bufferRow) + keys[i] = bufferRow } previousBufferRow = bufferRow } @@ -2500,12 +2504,12 @@ class LineNumberGutterComponent { render () { const { parentComponent, height, width, lineHeight, startRow, endRow, rowsPerTile, - maxDigits, keys, numbers, foldableFlags, decorations + maxDigits, keys, bufferRows, softWrappedFlags, foldableFlags, decorations } = this.props let children = null - if (numbers) { + if (bufferRows) { const renderedTileCount = parentComponent.getRenderedTileCount() children = new Array(renderedTileCount) @@ -2515,8 +2519,9 @@ class LineNumberGutterComponent { for (let row = tileStartRow; row < tileEndRow; row++) { const i = row - startRow const key = keys[i] + const softWrapped = softWrappedFlags[i] const foldable = foldableFlags[i] - let number = numbers[i] + const bufferRow = bufferRows[i] let className = 'line-number' if (foldable) className = className + ' foldable' @@ -2524,10 +2529,10 @@ class LineNumberGutterComponent { const decorationsForRow = decorations[row - startRow] if (decorationsForRow) className = className + ' ' + decorationsForRow - if (number === -1) number = '•' + let number = softWrapped ? '•' : bufferRow + 1 number = NBSP_CHARACTER.repeat(maxDigits - number.length) + number - let lineNumberProps = {key, className} + let lineNumberProps = {key, className, dataset: {bufferRow}} if (row === 0 || i > 0) { let currentRowTop = parentComponent.pixelPositionAfterBlocksForRow(row) @@ -2567,7 +2572,7 @@ class LineNumberGutterComponent { return $.div( { - className: 'gutter line-numbers', + className: 'gutter line-bufferRows', attributes: {'gutter-name': 'line-number'}, style: {position: 'relative', height: height + 'px'}, on: { @@ -2594,7 +2599,7 @@ class LineNumberGutterComponent { if (oldProps.maxDigits !== newProps.maxDigits) return true if (newProps.didMeasureVisibleBlockDecoration) return true if (!arraysEqual(oldProps.keys, newProps.keys)) return true - if (!arraysEqual(oldProps.numbers, newProps.numbers)) return true + if (!arraysEqual(oldProps.bufferRows, newProps.bufferRows)) return true if (!arraysEqual(oldProps.foldableFlags, newProps.foldableFlags)) return true if (!arraysEqual(oldProps.decorations, newProps.decorations)) return true From 15ecbed61fbe2dc638dc92028fff1dd4f46b20cd Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 17 Apr 2017 15:50:27 -0600 Subject: [PATCH 252/403] Don't pane focus when pane model is destroyed This avoids a non-failure error message when resetting the environment in some specs. --- src/pane-element.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pane-element.coffee b/src/pane-element.coffee index 2b8260db614..d6b2c0d2dd0 100644 --- a/src/pane-element.coffee +++ b/src/pane-element.coffee @@ -27,7 +27,7 @@ class PaneElement extends HTMLElement subscribeToDOMEvents: -> handleFocus = (event) => - @model.focus() unless @isActivating or @contains(event.relatedTarget) + @model.focus() unless @isActivating or @model.isDestroyed() or @contains(event.relatedTarget) if event.target is this and view = @getActiveView() view.focus() event.stopPropagation() From f7c55b94738ce3ab4a3233aa3adc97ec263d030e Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 18 Apr 2017 16:32:39 +0200 Subject: [PATCH 253/403] Honor the `updateSynchronously` parameter --- spec/text-editor-component-spec.js | 36 ++++++++++++++++++++++++++++++ src/text-editor-component.js | 1 + src/text-editor.coffee | 7 +++++- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index ad3b7dd2092..1f468975257 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1,6 +1,7 @@ const {it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise, timeoutPromise} = require('./async-spec-helpers') const TextEditorComponent = require('../src/text-editor-component') +const TextEditorElement = require('../src/text-editor-element') const TextEditor = require('../src/text-editor') const TextBuffer = require('text-buffer') const fs = require('fs') @@ -2381,6 +2382,41 @@ describe('TextEditorComponent', () => { }) }) + describe('synchronous updates', () => { + let editorElementWasUpdatedSynchronously + + beforeEach(() => { + editorElementWasUpdatedSynchronously = TextEditorElement.prototype.updatedSynchronously + }) + + afterEach(() => { + TextEditorElement.prototype.setUpdatedSynchronously(editorElementWasUpdatedSynchronously) + }) + + it('updates synchronously when updatedSynchronously is true', () => { + const editor = buildEditor() + const {element} = new TextEditorComponent({model: editor, updatedSynchronously: true}) + jasmine.attachToDOM(element) + + editor.setText('Lorem ipsum dolor') + expect(Array.from(element.querySelectorAll('.line:not(.dummy)')).map(l => l.textContent)).toEqual([ + editor.lineTextForScreenRow(0) + ]) + }) + + it('updates synchronously when creating a component via TextEditor and TextEditorElement.prototype.updatedSynchronously is true', () => { + TextEditorElement.prototype.setUpdatedSynchronously(true) + const editor = buildEditor() + const element = editor.element + jasmine.attachToDOM(element) + + editor.setText('Lorem ipsum dolor') + expect(Array.from(element.querySelectorAll('.line:not(.dummy)')).map(l => l.textContent)).toEqual([ + editor.lineTextForScreenRow(0) + ]) + }) + }) + describe('pixelPositionForScreenPositionSync(point)', () => { it('returns the pixel position for the given point, regardless of whether or not it is currently on screen', async () => { const {component, element, editor} = buildComponent({rowsPerTile: 2, autoHeight: false}) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index dd4d2f6400d..b40a02baea6 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -64,6 +64,7 @@ class TextEditorComponent { this.refs = {} this.updateSync = this.updateSync.bind(this) + this.updatedSynchronously = this.props.updatedSynchronously this.didScrollDummyScrollbar = this.didScrollDummyScrollbar.bind(this) this.didMouseDownOnContent = this.didMouseDownOnContent.bind(this) this.disposables = new CompositeDisposable() diff --git a/src/text-editor.coffee b/src/text-editor.coffee index ac0a05ca5d5..1b374404aa8 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -13,6 +13,7 @@ Selection = require './selection' TextMateScopeSelector = require('first-mate').ScopeSelector GutterContainer = require './gutter-container' TextEditorComponent = null +TextEditorElement = null {isDoubleWidthCharacter, isHalfWidthCharacter, isKoreanCharacter, isWrapBoundary} = require './text-utils' ZERO_WIDTH_NBSP = '\ufeff' @@ -3581,7 +3582,11 @@ class TextEditor extends Model @component.element else TextEditorComponent ?= require('./text-editor-component') - new TextEditorComponent({model: this, styleManager: atom.styles}) + TextEditorElement ?= require('./text-editor-element') + new TextEditorComponent({ + model: this, + updatedSynchronously: TextEditorElement.prototype.updatedSynchronously + }) @component.element # Essential: Retrieves the greyed out placeholder of a mini editor. From 6eed22aa90f439118a416f5a0d242f9281f96024 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 18 Apr 2017 18:49:27 +0200 Subject: [PATCH 254/403] Disconnect resize observers in overlay components on editor detach Signed-off-by: Nathan Sobo --- src/text-editor-component.js | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index b40a02baea6..01e0467401f 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -82,6 +82,7 @@ class TextEditorComponent { this.blockDecorationsToMeasure = new Set() this.lineNodesByScreenLineId = new Map() this.textNodesByScreenLineId = new Map() + this.overlayComponents = new Set() this.shouldRenderDummyScrollbars = true this.remeasureScrollbars = false this.pendingAutoscroll = null @@ -807,7 +808,11 @@ class TextEditorComponent { renderOverlayDecorations () { return this.decorationsToRender.overlays.map((overlayProps) => $(OverlayComponent, Object.assign( - {key: overlayProps.element, didResize: () => { this.updateSync() }}, + { + key: overlayProps.element, + overlayComponents: this.overlayComponents, + didResize: () => { this.updateSync() } + }, overlayProps )) ) @@ -1198,6 +1203,8 @@ class TextEditorComponent { this.gutterContainerResizeObserver.observe(this.refs.gutterContainer) } + this.overlayComponents.forEach((component) => component.didAttach()) + if (this.isVisible()) { this.didShow() } else { @@ -1215,6 +1222,7 @@ class TextEditorComponent { this.intersectionObserver.disconnect() this.resizeObserver.disconnect() if (this.gutterContainerResizeObserver) this.gutterContainerResizeObserver.disconnect() + this.overlayComponents.forEach((component) => component.didDetach()) this.didHide() this.attached = false @@ -3283,11 +3291,13 @@ class OverlayComponent { this.props.didResize() process.nextTick(() => { this.resizeObserver.observe(this.element) }) }) - this.resizeObserver.observe(this.element) + this.didAttach() + this.props.overlayComponents.add(this) } destroy () { - this.resizeObserver.disconnect() + this.props.overlayComponents.delete(this) + this.didDetach() } update (newProps) { @@ -3300,6 +3310,14 @@ class OverlayComponent { if (newProps.className != null) this.element.classList.add(newProps.className) } } + + didAttach () { + this.resizeObserver.observe(this.element) + } + + didDetach () { + this.resizeObserver.disconnect() + } } const classNamesByScopeName = new Map() From 55950f95948fdf6e37de4bff767ba2c453aa7e40 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 18 Apr 2017 19:03:32 +0200 Subject: [PATCH 255/403] Assign placeholder text on the model only when the attribute is present Signed-off-by: Nathan Sobo --- spec/text-editor-element-spec.js | 6 ++++++ src/text-editor-element.js | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/spec/text-editor-element-spec.js b/spec/text-editor-element-spec.js index ee1ef37cd50..294973c3ea5 100644 --- a/spec/text-editor-element-spec.js +++ b/spec/text-editor-element-spec.js @@ -24,6 +24,12 @@ describe('TextEditorElement', () => { expect(element.getModel().getPlaceholderText()).toBe('testing') }) + it("only assigns 'placeholder-text' on the model if the attribute is present", () => { + const editor = new TextEditor({placeholderText: 'placeholder'}) + editor.getElement() + expect(editor.getPlaceholderText()).toBe('placeholder') + }) + it("honors the 'gutter-hidden' attribute", () => { jasmineContent.innerHTML = '' const element = jasmineContent.firstChild diff --git a/src/text-editor-element.js b/src/text-editor-element.js index fe780f7db86..9bbfe675b6f 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -87,8 +87,8 @@ class TextEditorElement extends HTMLElement { updateModelFromAttributes () { const props = { mini: this.hasAttribute('mini'), - placeholderText: this.getAttribute('placeholder-text') } + if (this.hasAttribute('placeholder-text')) props.placeholderText = this.getAttribute('placeholder-text') if (this.hasAttribute('gutter-hidden')) props.lineNumberGutterVisible = false this.getModel().update(props) From 240a472d3af7a94d0de77b62c9e53e2f0cba333a Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 18 Apr 2017 19:12:44 +0200 Subject: [PATCH 256/403] Disable ResizeObserver temporarily in resize callback to avoid warning Signed-off-by: Nathan Sobo --- src/text-editor-component.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 01e0467401f..7c17c046cab 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1332,13 +1332,17 @@ class TextEditorComponent { this.remeasureAllBlockDecorations = true } + this.resizeObserver.disconnect() this.scheduleUpdate() + process.nextTick(() => { this.resizeObserver.observe(this.element) }) } } didResizeGutterContainer () { if (this.measureGutterDimensions()) { + this.gutterContainerResizeObserver.disconnect() this.scheduleUpdate() + process.nextTick(() => { this.gutterContainerResizeObserver.observe(this.refs.gutterContainer) }) } } From 4d8137a7f517b8fc2dd2aa41963578798a7e442f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 18 Apr 2017 14:45:29 -0600 Subject: [PATCH 257/403] Add keys to gutterContainer and scrollContainer to avoid recycling issue Previously, when the gutter container was removed due to the editor becoming mini, the lack of keys caused the gutter to be updated with the recycled cursors vnode. But then we tried to remove the cursors vnode not realizing it had been moved and tore down all the references. We probably need to revisit whether it makes sense to recycle vnodes. --- src/text-editor-component.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 7c17c046cab..30dbce51588 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -426,6 +426,7 @@ class TextEditorComponent { this.gutterContainerVnode = $.div( { ref: 'gutterContainer', + key: 'gutterContainer', className: 'gutter-container', style: { position: 'relative', @@ -506,6 +507,7 @@ class TextEditorComponent { return $.div( { ref: 'scrollContainer', + key: 'scrollContainer', className: 'scroll-view', style }, From 69a29b2c5889a43406d59242d6ba3e9f03644163 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 18 Apr 2017 15:44:06 -0600 Subject: [PATCH 258/403] Delegate (get|set)(Height|Width) to element Rather than storing these values on the editor model. --- spec/text-editor-component-spec.js | 26 ++++++++++++++++++++++++++ src/text-editor-element.js | 4 ++-- src/text-editor.coffee | 23 ++++++++--------------- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 1f468975257..506d7223f29 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -6,6 +6,7 @@ const TextEditor = require('../src/text-editor') const TextBuffer = require('text-buffer') const fs = require('fs') const path = require('path') +const Grim = require('grim') const SAMPLE_TEXT = fs.readFileSync(path.join(__dirname, 'fixtures', 'sample.js'), 'utf8') const NBSP_CHARACTER = '\u00a0' @@ -2446,6 +2447,31 @@ describe('TextEditorComponent', () => { }) }) + describe('model methods that delegate to the component / element', () => { + it('delegates setHeight and getHeight to the component', async () => { + const {component, element, editor} = buildComponent({autoHeight: false}) + spyOn(Grim, 'deprecate') + expect(editor.getHeight()).toBe(component.getScrollContainerHeight()) + expect(Grim.deprecate.callCount).toBe(1) + + editor.setHeight(100) + await component.getNextUpdatePromise() + expect(component.getScrollContainerHeight()).toBe(100) + expect(Grim.deprecate.callCount).toBe(2) + }) + + it('delegates setWidth and getWidth to the component', async () => { + const {component, element, editor} = buildComponent() + spyOn(Grim, 'deprecate') + expect(editor.getWidth()).toBe(component.getScrollContainerWidth()) + expect(Grim.deprecate.callCount).toBe(1) + + editor.setWidth(100) + await component.getNextUpdatePromise() + expect(component.getScrollContainerWidth()).toBe(100) + expect(Grim.deprecate.callCount).toBe(2) + }) + }) }) function buildEditor (params = {}) { diff --git a/src/text-editor-element.js b/src/text-editor-element.js index 9bbfe675b6f..d4f402c229e 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -108,7 +108,7 @@ class TextEditorElement extends HTMLElement { } getWidth () { - this.offsetWidth - this.getComponent().getGutterContainerWidth() + return this.getComponent().getScrollContainerWidth() } setHeight (height) { @@ -116,7 +116,7 @@ class TextEditorElement extends HTMLElement { } getHeight () { - return this.offsetHeight + return this.getComponent().getScrollContainerHeight() } onDidChangeScrollLeft (callback) { diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 1b374404aa8..12ae6e9f123 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -3650,32 +3650,25 @@ class TextEditor extends Model }) defaultCharWidth - setHeight: (height, reentrant=false) -> - if reentrant - @height = height - else - Grim.deprecate("This is now a view method. Call TextEditorElement::setHeight instead.") - @getElement().setHeight(height) + setHeight: (height) -> + Grim.deprecate("This is now a view method. Call TextEditorElement::setHeight instead.") + @getElement().setHeight(height) getHeight: -> Grim.deprecate("This is now a view method. Call TextEditorElement::getHeight instead.") - @height + @getElement().getHeight() getAutoHeight: -> @autoHeight ? true getAutoWidth: -> @autoWidth ? false - setWidth: (width, fromComponent=false) -> - if fromComponent - @update({width}) - @width - else - Grim.deprecate("This is now a view method. Call TextEditorElement::setWidth instead.") - @getElement().setWidth(width) + setWidth: (width) -> + Grim.deprecate("This is now a view method. Call TextEditorElement::setWidth instead.") + @getElement().setWidth(width) getWidth: -> Grim.deprecate("This is now a view method. Call TextEditorElement::getWidth instead.") - @width + @getElement().getWidth() # Experimental: Scroll the editor such that the given screen row is at the # top of the visible area. From 19f5535d68b03db54755630425ae9d6b6b0c95f7 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 18 Apr 2017 16:28:12 -0600 Subject: [PATCH 259/403] Add back the measureDimensions method since some packages rely on it Ideally, packages would resize and then wait for an update. But we set up an example of calling measureDimensions directly in find-and-replace so the easiest thing for now is just to keep this method around. --- spec/text-editor-component-spec.js | 12 ++++++++++++ src/text-editor-component.js | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 506d7223f29..e978e03281d 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -2416,6 +2416,18 @@ describe('TextEditorComponent', () => { editor.lineTextForScreenRow(0) ]) }) + + it('measures dimensions synchronously when measureDimensions is called on the component', () => { + TextEditorElement.prototype.setUpdatedSynchronously(true) + const editor = buildEditor({autoHeight: false}) + const element = editor.element + jasmine.attachToDOM(element) + + element.style.height = '100px' + expect(element.component.getClientContainerHeight()).not.toBe(100) + element.component.measureDimensions() + expect(element.component.getClientContainerHeight()).toBe(100) + }) }) describe('pixelPositionForScreenPositionSync(point)', () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 30dbce51588..eefff77a9a9 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1799,6 +1799,12 @@ class TextEditorComponent { performInitialMeasurements () { this.measurements = {} + this.measureDimensions() + } + + // This method exists because it existed in the previous implementation and some + // package tests relied on it + measureDimensions () { this.measureCharacterDimensions() this.measureGutterDimensions() this.measureClientContainerHeight() From 493b735740d904cab1dd36d87cb2807d61681e04 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 18 Apr 2017 16:28:37 -0600 Subject: [PATCH 260/403] Delegate getFirst/LastVisibleScreenRow from model to component --- spec/text-editor-component-spec.js | 11 +++++++++++ src/text-editor.coffee | 13 +++++-------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index e978e03281d..d920750fda2 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -2483,6 +2483,17 @@ describe('TextEditorComponent', () => { expect(component.getScrollContainerWidth()).toBe(100) expect(Grim.deprecate.callCount).toBe(2) }) + + it('delegates getFirstVisibleScreenRow, getLastVisibleScreenRow, and getVisibleRowRange to the component', async () => { + const {component, element, editor} = buildComponent({rowsPerTile: 3, autoHeight: false}) + element.style.height = 4 * component.measurements.lineHeight + 'px' + await component.getNextUpdatePromise() + await setScrollTop(component, 5 * component.getLineHeight()) + + expect(editor.getFirstVisibleScreenRow()).toBe(component.getFirstVisibleRow()) + expect(editor.getLastVisibleScreenRow()).toBe(component.getLastVisibleRow()) + expect(editor.getVisibleRowRange()).toEqual([component.getFirstVisibleRow(), component.getLastVisibleRow()]) + }) }) }) diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 12ae6e9f123..5744c3cd787 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -3686,17 +3686,14 @@ class TextEditor extends Model getFirstVisibleScreenRow: -> @firstVisibleScreenRow + getFirstVisibleScreenRow: -> + @getElement().component.getFirstVisibleRow() + getLastVisibleScreenRow: -> - if @height? and @lineHeightInPixels? - Math.min(@firstVisibleScreenRow + Math.floor(@height / @lineHeightInPixels), @getScreenLineCount() - 1) - else - null + @getElement().component.getLastVisibleRow() getVisibleRowRange: -> - if lastVisibleScreenRow = @getLastVisibleScreenRow() - [@firstVisibleScreenRow, lastVisibleScreenRow] - else - null + [@getFirstVisibleScreenRow(), @getLastVisibleScreenRow()] setFirstVisibleScreenColumn: (@firstVisibleScreenColumn) -> getFirstVisibleScreenColumn: -> @firstVisibleScreenColumn From 4f5263751892cf713dd8c82df742094b007d6094 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 18 Apr 2017 16:43:59 -0600 Subject: [PATCH 261/403] Delegate setFirstVisibleScreenRow from the model to the component --- spec/text-editor-component-spec.js | 17 +++++++++++++++++ src/text-editor-component.js | 4 ++++ src/text-editor.coffee | 18 ++---------------- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index d920750fda2..5a7fccffbc6 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -2494,6 +2494,23 @@ describe('TextEditorComponent', () => { expect(editor.getLastVisibleScreenRow()).toBe(component.getLastVisibleRow()) expect(editor.getVisibleRowRange()).toEqual([component.getFirstVisibleRow(), component.getLastVisibleRow()]) }) + + it('assigns scrollTop on the component when calling setFirstVisibleScreenRow', async () => { + const {component, element, editor} = buildComponent({rowsPerTile: 3, autoHeight: false}) + element.style.height = 4 * component.measurements.lineHeight + 'px' + await component.getNextUpdatePromise() + + expect(component.getMaxScrollTop() / component.getLineHeight()).toBe(9) + + editor.setFirstVisibleScreenRow(1) + expect(component.getFirstVisibleRow()).toBe(1) + + editor.setFirstVisibleScreenRow(5) + expect(component.getFirstVisibleRow()).toBe(5) + + editor.setFirstVisibleScreenRow(11) + expect(component.getFirstVisibleRow()).toBe(9) + }) }) }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index eefff77a9a9..369395d7fdd 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2310,6 +2310,10 @@ class TextEditorComponent { return Math.ceil(this.getRenderedRowCount() / this.getRowsPerTile()) } + setFirstVisibleRow (row) { + this.setScrollTop(this.pixelPositionBeforeBlocksForRow(row)) + } + getFirstVisibleRow () { return this.rowForPixelPosition(this.getScrollTop()) } diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 5744c3cd787..21d80135dea 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -731,9 +731,6 @@ class TextEditor extends Model onDidChangePlaceholderText: (callback) -> @emitter.on 'did-change-placeholder-text', callback - onDidChangeFirstVisibleScreenRow: (callback, fromView) -> - @emitter.on 'did-change-first-visible-screen-row', callback - onDidChangeScrollTop: (callback) -> Grim.deprecate("This is now a view method. Call TextEditorElement::onDidChangeScrollTop instead.") @@ -3672,19 +3669,8 @@ class TextEditor extends Model # Experimental: Scroll the editor such that the given screen row is at the # top of the visible area. - setFirstVisibleScreenRow: (screenRow, fromView) -> - unless fromView - maxScreenRow = @getScreenLineCount() - 1 - unless @scrollPastEnd - if @height? and @lineHeightInPixels? - maxScreenRow -= Math.floor(@height / @lineHeightInPixels) - screenRow = Math.max(Math.min(screenRow, maxScreenRow), 0) - - unless screenRow is @firstVisibleScreenRow - @firstVisibleScreenRow = screenRow - @emitter.emit 'did-change-first-visible-screen-row', screenRow unless fromView - - getFirstVisibleScreenRow: -> @firstVisibleScreenRow + setFirstVisibleScreenRow: (screenRow) -> + @getElement().component.setFirstVisibleRow(screenRow) getFirstVisibleScreenRow: -> @getElement().component.getFirstVisibleRow() From eb7cdf2a34dd49507ab1467ce303ae81b2f8320a Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 18 Apr 2017 16:49:22 -0600 Subject: [PATCH 262/403] Delegate get/setFirstVisibleScreenColumn from the model to the component --- spec/text-editor-component-spec.js | 13 +++++++++++++ src/text-editor-component.js | 8 ++++++++ src/text-editor.coffee | 7 +++++-- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 5a7fccffbc6..def4d68e7ba 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -2511,6 +2511,19 @@ describe('TextEditorComponent', () => { editor.setFirstVisibleScreenRow(11) expect(component.getFirstVisibleRow()).toBe(9) }) + + it('delegates setFirstVisibleScreenColumn and getFirstVisibleScreenColumn to the component', async () => { + const {component, element, editor} = buildComponent({rowsPerTile: 3, autoHeight: false}) + element.style.width = 30 * component.getBaseCharacterWidth() + 'px' + await component.getNextUpdatePromise() + + expect(editor.getFirstVisibleScreenColumn()).toBe(0) + component.setScrollLeft(5.5 * component.getBaseCharacterWidth()) + expect(editor.getFirstVisibleScreenColumn()).toBe(5) + + editor.setFirstVisibleScreenColumn(12) + expect(component.getScrollLeft()).toBe(Math.round(12 * component.getBaseCharacterWidth())) + }) }) }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 369395d7fdd..c57e58715a7 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2325,6 +2325,14 @@ class TextEditorComponent { ) } + getFirstVisibleColumn () { + return Math.floor(this.getScrollLeft() / this.getBaseCharacterWidth()) + } + + setFirstVisibleColumn (column) { + this.setScrollLeft(column * this.getBaseCharacterWidth()) + } + getVisibleTileCount () { return Math.floor((this.getLastVisibleRow() - this.getFirstVisibleRow()) / this.getRowsPerTile()) + 2 } diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 21d80135dea..31673736b61 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -3681,8 +3681,11 @@ class TextEditor extends Model getVisibleRowRange: -> [@getFirstVisibleScreenRow(), @getLastVisibleScreenRow()] - setFirstVisibleScreenColumn: (@firstVisibleScreenColumn) -> - getFirstVisibleScreenColumn: -> @firstVisibleScreenColumn + setFirstVisibleScreenColumn: (column) -> + @getElement().component.setFirstVisibleColumn(column) + + getFirstVisibleScreenColumn: -> + @getElement().component.getFirstVisibleColumn() getScrollTop: -> Grim.deprecate("This is now a view method. Call TextEditorElement::getScrollTop instead.") From 5ea409646444c2d60db6be9b86b937a52664d611 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 18 Apr 2017 17:42:43 -0600 Subject: [PATCH 263/403] Guard gitFirst/LastVisibleScreenRow These methods are sometimes called by the model before the editor has been attached to the DOM. --- src/text-editor-component.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index c57e58715a7..e896989af1c 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2315,14 +2315,18 @@ class TextEditorComponent { } getFirstVisibleRow () { - return this.rowForPixelPosition(this.getScrollTop()) + if (this.measurements) { + return this.rowForPixelPosition(this.getScrollTop()) + } } getLastVisibleRow () { - return Math.min( - this.props.model.getApproximateScreenLineCount() - 1, - this.rowForPixelPosition(this.getScrollBottom()) - ) + if (this.measurements) { + return Math.min( + this.props.model.getApproximateScreenLineCount() - 1, + this.rowForPixelPosition(this.getScrollBottom()) + ) + } } getFirstVisibleColumn () { From e232a868c5c6213d4ed93df6f226bd6d95de68a0 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 18 Apr 2017 17:43:11 -0600 Subject: [PATCH 264/403] Drop tests for set/getFirstVisibleScreenRow These are now tested in text-editor-component-spec --- spec/text-editor-spec.coffee | 66 ------------------------------------ 1 file changed, 66 deletions(-) diff --git a/spec/text-editor-spec.coffee b/spec/text-editor-spec.coffee index 1d50e0b79cc..96d897fb9a7 100644 --- a/spec/text-editor-spec.coffee +++ b/spec/text-editor-spec.coffee @@ -5521,72 +5521,6 @@ describe "TextEditor", -> editor.selectPageUp() expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [12, 2]]] - describe "::setFirstVisibleScreenRow() and ::getFirstVisibleScreenRow()", -> - beforeEach -> - line = Array(9).join('0123456789') - editor.setText([1..100].map(-> line).join('\n')) - expect(editor.getLineCount()).toBe 100 - expect(editor.lineTextForBufferRow(0).length).toBe 80 - - describe "when the editor doesn't have a height and lineHeightInPixels", -> - it "does not affect the editor's visible row range", -> - expect(editor.getVisibleRowRange()).toBeNull() - - editor.setFirstVisibleScreenRow(1) - expect(editor.getFirstVisibleScreenRow()).toEqual 1 - - editor.setFirstVisibleScreenRow(3) - expect(editor.getFirstVisibleScreenRow()).toEqual 3 - - expect(editor.getVisibleRowRange()).toBeNull() - expect(editor.getLastVisibleScreenRow()).toBeNull() - - describe "when the editor has a height and lineHeightInPixels", -> - beforeEach -> - editor.update({scrollPastEnd: true}) - editor.setHeight(100, true) - editor.setLineHeightInPixels(10) - - it "updates the editor's visible row range", -> - editor.setFirstVisibleScreenRow(2) - expect(editor.getFirstVisibleScreenRow()).toEqual 2 - expect(editor.getLastVisibleScreenRow()).toBe 12 - expect(editor.getVisibleRowRange()).toEqual [2, 12] - - it "notifies ::onDidChangeFirstVisibleScreenRow observers", -> - changeCount = 0 - editor.onDidChangeFirstVisibleScreenRow -> changeCount++ - - editor.setFirstVisibleScreenRow(2) - expect(changeCount).toBe 1 - - editor.setFirstVisibleScreenRow(2) - expect(changeCount).toBe 1 - - editor.setFirstVisibleScreenRow(3) - expect(changeCount).toBe 2 - - it "ensures that the top row is less than the buffer's line count", -> - editor.setFirstVisibleScreenRow(102) - expect(editor.getFirstVisibleScreenRow()).toEqual 99 - expect(editor.getVisibleRowRange()).toEqual [99, 99] - - it "ensures that the left column is less than the length of the longest screen line", -> - editor.setFirstVisibleScreenRow(10) - expect(editor.getFirstVisibleScreenRow()).toEqual 10 - - editor.setText("\n\n\n") - - editor.setFirstVisibleScreenRow(10) - expect(editor.getFirstVisibleScreenRow()).toEqual 3 - - describe "when the 'editor.scrollPastEnd' option is set to false", -> - it "ensures that the bottom row is less than the buffer's line count", -> - editor.update({scrollPastEnd: false}) - editor.setFirstVisibleScreenRow(95) - expect(editor.getFirstVisibleScreenRow()).toEqual 89 - expect(editor.getVisibleRowRange()).toEqual [89, 99] - describe "::scrollToScreenPosition(position, [options])", -> it "triggers ::onDidRequestAutoscroll with the logical coordinates along with the options", -> scrollSpy = jasmine.createSpy("::onDidRequestAutoscroll") From 9da6e22487bb8b864fe9c64e38d2d2744556d442 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 18 Apr 2017 17:47:58 -0600 Subject: [PATCH 265/403] Return false from isLineCommentedAtBufferRow if no line yet exists --- src/language-mode.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/language-mode.coffee b/src/language-mode.coffee index 06990bad56e..1839f1c59d4 100644 --- a/src/language-mode.coffee +++ b/src/language-mode.coffee @@ -189,7 +189,7 @@ class LanguageMode # row is a comment. isLineCommentedAtBufferRow: (bufferRow) -> return false unless 0 <= bufferRow <= @editor.getLastBufferRow() - @editor.tokenizedBuffer.tokenizedLines[bufferRow]?.isComment() + @editor.tokenizedBuffer.tokenizedLines[bufferRow]?.isComment() ? false # Find a row range for a 'paragraph' around specified bufferRow. A paragraph # is a block of text bounded by and empty line or a block of text that is not From 129749f2ff232d2bc7d464285c70f83b944b5e60 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 18 Apr 2017 20:12:39 -0600 Subject: [PATCH 266/403] Set updatedSynchronously to false in text-editor-element-spec --- spec/text-editor-element-spec.js | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/spec/text-editor-element-spec.js b/spec/text-editor-element-spec.js index 294973c3ea5..686875f04ce 100644 --- a/spec/text-editor-element-spec.js +++ b/spec/text-editor-element-spec.js @@ -11,6 +11,12 @@ describe('TextEditorElement', () => { jasmineContent = document.body.querySelector('#jasmine-content') }) + function buildTextEditorElement () { + const element = new TextEditorElement() + element.setUpdatedSynchronously(false) + return element + } + describe('instantiation', () => { it("honors the 'mini' attribute", () => { jasmineContent.innerHTML = '' @@ -45,7 +51,7 @@ describe('TextEditorElement', () => { describe('when the model is assigned', () => it("adds the 'mini' attribute if .isMini() returns true on the model", function (done) { - const element = new TextEditorElement() + const element = buildTextEditorElement() element.getModel().update({mini: true}) atom.views.getNextUpdatePromise().then(() => { expect(element.hasAttribute('mini')).toBe(true) @@ -56,7 +62,7 @@ describe('TextEditorElement', () => { describe('when the editor is attached to the DOM', () => it('mounts the component and unmounts when removed from the dom', () => { - const element = new TextEditorElement() + const element = buildTextEditorElement() jasmine.attachToDOM(element) const { component } = element @@ -117,7 +123,7 @@ describe('TextEditorElement', () => { describe('focus and blur handling', () => { it('proxies focus/blur events to/from the hidden input', () => { - const element = new TextEditorElement() + const element = buildTextEditorElement() jasmineContent.appendChild(element) let blurCalled = false @@ -136,7 +142,7 @@ describe('TextEditorElement', () => { it("doesn't trigger a blur event on the editor element when focusing an already focused editor element", () => { let blurCalled = false - const element = new TextEditorElement() + const element = buildTextEditorElement() element.addEventListener('blur', () => { blurCalled = true }) jasmineContent.appendChild(element) @@ -164,7 +170,7 @@ describe('TextEditorElement', () => { ) it('proxies the focus event to the hidden input', () => { - const element = new TextEditorElement() + const element = buildTextEditorElement() const parentElement = document.createElement('element-that-focuses-child') parentElement.appendChild(element) jasmineContent.appendChild(parentElement) @@ -175,7 +181,7 @@ describe('TextEditorElement', () => { describe('::onDidAttach and ::onDidDetach', () => it('invokes callbacks when the element is attached and detached', () => { - const element = new TextEditorElement() + const element = buildTextEditorElement() const attachedCallback = jasmine.createSpy('attachedCallback') const detachedCallback = jasmine.createSpy('detachedCallback') @@ -200,10 +206,9 @@ describe('TextEditorElement', () => { it('controls whether the text editor is updated synchronously', () => { spyOn(window, 'requestAnimationFrame').andCallFake(fn => fn()) - const element = new TextEditorElement() + const element = buildTextEditorElement() jasmine.attachToDOM(element) - element.setUpdatedSynchronously(false) expect(element.isUpdatedSynchronously()).toBe(false) element.getModel().setText('hello') @@ -221,12 +226,12 @@ describe('TextEditorElement', () => { describe('::getDefaultCharacterWidth', () => { it('returns null before the element is attached', () => { - const element = new TextEditorElement() + const element = buildTextEditorElement() expect(element.getDefaultCharacterWidth()).toBeNull() }) it('returns the width of a character in the root scope', () => { - const element = new TextEditorElement() + const element = buildTextEditorElement() jasmine.attachToDOM(element) expect(element.getDefaultCharacterWidth()).toBeGreaterThan(0) }) @@ -260,7 +265,7 @@ describe('TextEditorElement', () => { describe('on TextEditor::setMini', () => it("changes the element's 'mini' attribute", async () => { - const element = new TextEditorElement() + const element = buildTextEditorElement() jasmine.attachToDOM(element) expect(element.hasAttribute('mini')).toBe(false) element.getModel().setMini(true) @@ -276,7 +281,7 @@ describe('TextEditorElement', () => { let element = null beforeEach(() => { - element = new TextEditorElement() + element = buildTextEditorElement() element.getModel().setText('lorem\nipsum\ndolor\nsit\namet') element.setUpdatedSynchronously(true) element.setHeight(20) From 996e0462b78e16cbf0dfcbf62e94254ee1dfca91 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 18 Apr 2017 20:38:51 -0600 Subject: [PATCH 267/403] Don't update synchronously in text-editor-element-spec --- spec/text-editor-element-spec.js | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/spec/text-editor-element-spec.js b/spec/text-editor-element-spec.js index 686875f04ce..26f0328b2e1 100644 --- a/spec/text-editor-element-spec.js +++ b/spec/text-editor-element-spec.js @@ -11,9 +11,10 @@ describe('TextEditorElement', () => { jasmineContent = document.body.querySelector('#jasmine-content') }) - function buildTextEditorElement () { + function buildTextEditorElement (options = {}) { const element = new TextEditorElement() element.setUpdatedSynchronously(false) + if (options.attach !== false) jasmine.attachToDOM(element) return element } @@ -63,7 +64,6 @@ describe('TextEditorElement', () => { describe('when the editor is attached to the DOM', () => it('mounts the component and unmounts when removed from the dom', () => { const element = buildTextEditorElement() - jasmine.attachToDOM(element) const { component } = element expect(component.attached).toBe(true) @@ -80,7 +80,6 @@ describe('TextEditorElement', () => { const editor = new TextEditor() editor.setText('1\n2\n3') const element = editor.getElement() - jasmine.attachToDOM(element) const initialCount = element.querySelectorAll('.line-number').length @@ -107,8 +106,7 @@ describe('TextEditorElement', () => { }) it('can be re-focused using the previous `document.activeElement`', () => { - const editorElement = document.createElement('atom-text-editor') - jasmine.attachToDOM(editorElement) + const editorElement = buildTextEditorElement() editorElement.focus() const { activeElement } = document @@ -181,7 +179,7 @@ describe('TextEditorElement', () => { describe('::onDidAttach and ::onDidDetach', () => it('invokes callbacks when the element is attached and detached', () => { - const element = buildTextEditorElement() + const element = buildTextEditorElement({attach: false}) const attachedCallback = jasmine.createSpy('attachedCallback') const detachedCallback = jasmine.createSpy('detachedCallback') @@ -190,7 +188,6 @@ describe('TextEditorElement', () => { element.onDidDetach(detachedCallback) jasmine.attachToDOM(element) - expect(attachedCallback).toHaveBeenCalled() expect(detachedCallback).not.toHaveBeenCalled() @@ -226,7 +223,7 @@ describe('TextEditorElement', () => { describe('::getDefaultCharacterWidth', () => { it('returns null before the element is attached', () => { - const element = buildTextEditorElement() + const element = buildTextEditorElement({attach: false}) expect(element.getDefaultCharacterWidth()).toBeNull() }) @@ -266,7 +263,6 @@ describe('TextEditorElement', () => { describe('on TextEditor::setMini', () => it("changes the element's 'mini' attribute", async () => { const element = buildTextEditorElement() - jasmine.attachToDOM(element) expect(element.hasAttribute('mini')).toBe(false) element.getModel().setMini(true) await element.getNextUpdatePromise() @@ -280,20 +276,20 @@ describe('TextEditorElement', () => { describe('events', () => { let element = null - beforeEach(() => { + beforeEach(async () => { element = buildTextEditorElement() + element.getModel().update({autoHeight: false}) element.getModel().setText('lorem\nipsum\ndolor\nsit\namet') - element.setUpdatedSynchronously(true) element.setHeight(20) + await element.getNextUpdatePromise() element.setWidth(20) - element.getModel().update({autoHeight: false}) + await element.getNextUpdatePromise() }) describe('::onDidChangeScrollTop(callback)', () => it('triggers even when subscribing before attaching the element', () => { const positions = [] const subscription1 = element.onDidChangeScrollTop(p => positions.push(p)) - jasmine.attachToDOM(element) element.onDidChangeScrollTop(p => positions.push(p)) positions.length = 0 @@ -319,7 +315,6 @@ describe('TextEditorElement', () => { it('triggers even when subscribing before attaching the element', () => { const positions = [] const subscription1 = element.onDidChangeScrollLeft(p => positions.push(p)) - jasmine.attachToDOM(element) element.onDidChangeScrollLeft(p => positions.push(p)) positions.length = 0 From 4c8fd0cb7596dc126ecd7dc2c4fe32ce98ff587a Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 18 Apr 2017 20:39:05 -0600 Subject: [PATCH 268/403] Add tests for TextEditorElement.setScrollTop/Left --- spec/text-editor-element-spec.js | 20 ++++++++++++++++++++ src/text-editor-element.js | 8 ++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/spec/text-editor-element-spec.js b/spec/text-editor-element-spec.js index 26f0328b2e1..114c188d596 100644 --- a/spec/text-editor-element-spec.js +++ b/spec/text-editor-element-spec.js @@ -260,6 +260,26 @@ describe('TextEditorElement', () => { }) ) + describe('::setScrollTop and ::setScrollLeft', () => { + it('changes the scroll position', async () => { + element = buildTextEditorElement() + element.getModel().update({autoHeight: false}) + element.getModel().setText('lorem\nipsum\ndolor\nsit\namet') + element.setHeight(20) + await element.getNextUpdatePromise() + element.setWidth(20) + await element.getNextUpdatePromise() + + element.setScrollTop(22) + await element.getNextUpdatePromise() + expect(element.getScrollTop()).toBe(22) + + element.setScrollLeft(32) + await element.getNextUpdatePromise() + expect(element.getScrollLeft()).toBe(32) + }) + }) + describe('on TextEditor::setMini', () => it("changes the element's 'mini' attribute", async () => { const element = buildTextEditorElement() diff --git a/src/text-editor-element.js b/src/text-editor-element.js index d4f402c229e..e4b9e562888 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -149,7 +149,9 @@ class TextEditorElement extends HTMLElement { } setScrollTop (scrollTop) { - this.getComponent().setScrollTop(scrollTop) + const component = this.getComponent() + component.setScrollTop(scrollTop) + component.scheduleUpdate() } getScrollLeft () { @@ -157,7 +159,9 @@ class TextEditorElement extends HTMLElement { } setScrollLeft (scrollLeft) { - this.getComponent().setScrollLeft(scrollLeft) + const component = this.getComponent() + component.setScrollLeft(scrollLeft) + component.scheduleUpdate() } hasFocus () { From 3d29db49a4c4a3c47fe64ae80997c4ca77240253 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 19 Apr 2017 09:28:00 +0200 Subject: [PATCH 269/403] Use `position: relative` for `.line-number` elements ...because packages like `.git-diff` are relying on this behavior to position their decorations. This didn't seem to degrade layout times, so it makes sense to just add it to keep package breakage to a minimum. --- static/text-editor.less | 1 + 1 file changed, 1 insertion(+) diff --git a/static/text-editor.less b/static/text-editor.less index ab53762fba1..ed3798d4029 100644 --- a/static/text-editor.less +++ b/static/text-editor.less @@ -18,6 +18,7 @@ atom-text-editor { padding-left: .5em; white-space: nowrap; opacity: 0.6; + position: relative; .icon-right { .octicon(chevron-down, 0.8em); From 552fbf7915edbd78a9fd39dea0f945d847d1b916 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 19 Apr 2017 10:56:59 +0200 Subject: [PATCH 270/403] Honor the gutter-hidden attribute correctly --- spec/text-editor-element-spec.js | 56 +++++++++++++++++--------------- src/text-editor-element.js | 2 +- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/spec/text-editor-element-spec.js b/spec/text-editor-element-spec.js index 114c188d596..6ea67715287 100644 --- a/spec/text-editor-element-spec.js +++ b/spec/text-editor-element-spec.js @@ -18,36 +18,40 @@ describe('TextEditorElement', () => { return element } - describe('instantiation', () => { - it("honors the 'mini' attribute", () => { - jasmineContent.innerHTML = '' - const element = jasmineContent.firstChild - expect(element.getModel().isMini()).toBe(true) - }) + it("honors the 'mini' attribute", () => { + jasmineContent.innerHTML = '' + const element = jasmineContent.firstChild + expect(element.getModel().isMini()).toBe(true) + }) - it("honors the 'placeholder-text' attribute", () => { - jasmineContent.innerHTML = "" - const element = jasmineContent.firstChild - expect(element.getModel().getPlaceholderText()).toBe('testing') - }) + it("honors the 'placeholder-text' attribute", () => { + jasmineContent.innerHTML = "" + const element = jasmineContent.firstChild + expect(element.getModel().getPlaceholderText()).toBe('testing') + }) - it("only assigns 'placeholder-text' on the model if the attribute is present", () => { - const editor = new TextEditor({placeholderText: 'placeholder'}) - editor.getElement() - expect(editor.getPlaceholderText()).toBe('placeholder') - }) + it("only assigns 'placeholder-text' on the model if the attribute is present", () => { + const editor = new TextEditor({placeholderText: 'placeholder'}) + editor.getElement() + expect(editor.getPlaceholderText()).toBe('placeholder') + }) - it("honors the 'gutter-hidden' attribute", () => { - jasmineContent.innerHTML = '' - const element = jasmineContent.firstChild - expect(element.getModel().isLineNumberGutterVisible()).toBe(false) - }) + it("honors the 'gutter-hidden' attribute", () => { + jasmineContent.innerHTML = '' + const element = jasmineContent.firstChild + expect(element.getModel().isLineNumberGutterVisible()).toBe(false) - it('honors the text content', () => { - jasmineContent.innerHTML = 'testing' - const element = jasmineContent.firstChild - expect(element.getModel().getText()).toBe('testing') - }) + element.removeAttribute('gutter-hidden') + expect(element.getModel().isLineNumberGutterVisible()).toBe(true) + + element.setAttribute('gutter-hidden', '') + expect(element.getModel().isLineNumberGutterVisible()).toBe(false) + }) + + it('honors the text content', () => { + jasmineContent.innerHTML = 'testing' + const element = jasmineContent.firstChild + expect(element.getModel().getText()).toBe('testing') }) describe('when the model is assigned', () => diff --git a/src/text-editor-element.js b/src/text-editor-element.js index e4b9e562888..f9f270c2df3 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -58,7 +58,7 @@ class TextEditorElement extends HTMLElement { this.getModel().update({placeholderText: newValue}) break case 'gutter-hidden': - this.getModel().update({isVisible: newValue != null}) + this.getModel().update({lineNumberGutterVisible: newValue == null}) break } } From 2a688db26bdf4e9c3b8b3d3b8985d8719eb5bfb9 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 19 Apr 2017 11:00:34 +0200 Subject: [PATCH 271/403] Add better test coverage for the mini and placeholder-text attributes --- spec/text-editor-element-spec.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/spec/text-editor-element-spec.js b/spec/text-editor-element-spec.js index 6ea67715287..4a6655714a4 100644 --- a/spec/text-editor-element-spec.js +++ b/spec/text-editor-element-spec.js @@ -22,12 +22,24 @@ describe('TextEditorElement', () => { jasmineContent.innerHTML = '' const element = jasmineContent.firstChild expect(element.getModel().isMini()).toBe(true) + + element.removeAttribute('mini') + expect(element.getModel().isMini()).toBe(false) + + element.setAttribute('mini', '') + expect(element.getModel().isMini()).toBe(true) }) it("honors the 'placeholder-text' attribute", () => { jasmineContent.innerHTML = "" const element = jasmineContent.firstChild expect(element.getModel().getPlaceholderText()).toBe('testing') + + element.setAttribute('placeholder-text', 'placeholder') + expect(element.getModel().getPlaceholderText()).toBe('placeholder') + + element.removeAttribute('placeholder-text') + expect(element.getModel().getPlaceholderText()).toBeNull() }) it("only assigns 'placeholder-text' on the model if the attribute is present", () => { From 9ccfd3415c1c57561406f51ad04c1e3517400cb2 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 19 Apr 2017 12:02:24 +0200 Subject: [PATCH 272/403] Remeasure gutter dimensions when a gutter changes its visibility --- spec/text-editor-component-spec.js | 8 ++++++++ src/text-editor-component.js | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index def4d68e7ba..5175b895d85 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1209,6 +1209,14 @@ describe('TextEditorComponent', () => { await component.getNextUpdatePromise() checkScrollContainerLeft() + gutterA.hide() + await component.getNextUpdatePromise() + checkScrollContainerLeft() + + gutterA.show() + await component.getNextUpdatePromise() + checkScrollContainerLeft() + gutterA.destroy() await component.getNextUpdatePromise() checkScrollContainerLeft() diff --git a/src/text-editor-component.js b/src/text-editor-component.js index e896989af1c..28d50443d42 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -97,6 +97,7 @@ class TextEditorComponent { this.accentedCharacterMenuIsOpen = false this.remeasureGutterDimensions = false this.guttersToRender = [this.props.model.getLineNumberGutter()] + this.guttersVisibility = [this.guttersToRender[0].visible] this.lineNumbersToRender = { maxDigits: 2, bufferRows: [], @@ -897,13 +898,15 @@ class TextEditorComponent { queryGuttersToRender () { const oldGuttersToRender = this.guttersToRender + const oldGuttersVisibility = this.guttersVisibility this.guttersToRender = this.props.model.getGutters() + this.guttersVisibility = this.guttersToRender.map(g => g.visible) if (!oldGuttersToRender || oldGuttersToRender.length !== this.guttersToRender.length) { this.remeasureGutterDimensions = true } else { for (let i = 0, length = this.guttersToRender.length; i < length; i++) { - if (this.guttersToRender[i] !== oldGuttersToRender[i]) { + if (this.guttersToRender[i] !== oldGuttersToRender[i] || this.guttersVisibility[i] !== oldGuttersVisibility[i]) { this.remeasureGutterDimensions = true break } From f9cb1f87a789da0545e4c3718c075bf6234b04dc Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 19 Apr 2017 13:16:34 +0200 Subject: [PATCH 273/403] Implement `TextEditor.prototype.getRowsPerPage` --- src/text-editor.coffee | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 31673736b61..a97732e9639 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -3485,9 +3485,12 @@ class TextEditor extends Model # Returns the number of rows per page getRowsPerPage: -> - Math.max(@rowsPerPage ? 1, 1) - - setRowsPerPage: (@rowsPerPage) -> + if @component? + clientHeight = @component.getScrollContainerClientHeight() + lineHeight = @component.getLineHeight() + Math.max(1, Math.ceil(clientHeight / lineHeight)) + else + 1 ### Section: Config From f2070ef880e3e73eefa0a1020634241b745e0f7e Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 18 Apr 2017 21:28:05 -0600 Subject: [PATCH 274/403] Restore editor scroll position across reloads This commit introduces the concept of a scrollTopRow and scrollLeftColumn which is used to query and update the logical scroll position. --- spec/text-editor-component-spec.js | 63 +++++++++++++++++++++++++++ spec/text-editor-spec.coffee | 25 ++++++++--- src/text-editor-component.js | 68 +++++++++++++++++++++++++----- src/text-editor.coffee | 34 ++++++++++----- 4 files changed, 162 insertions(+), 28 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 5175b895d85..79f2cf7ff07 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -747,6 +747,69 @@ describe('TextEditorComponent', () => { }) }) + describe('logical scroll positions', () => { + it('allows the scrollTop to be changed and queried in terms of rows via setScrollTopRow and getScrollTopRow', () => { + const {component, element, editor} = buildComponent({attach: false, height: 80}) + + // Caches the scrollTopRow if we don't have measurements + component.setScrollTopRow(6) + expect(component.getScrollTopRow()).toBe(6) + + // Assigns the scrollTop based on the logical position when attached + jasmine.attachToDOM(element) + const expectedScrollTop = Math.round(6 * component.getLineHeight()) + expect(component.getScrollTopRow()).toBe(6) + expect(component.getScrollTop()).toBe(expectedScrollTop) + expect(component.refs.content.style.transform).toBe(`translate(0px, -${expectedScrollTop}px)`) + + // Allows the scrollTopRow to be updated while attached + component.setScrollTopRow(4) + expect(component.getScrollTopRow()).toBe(4) + expect(component.getScrollTop()).toBe(Math.round(4 * component.getLineHeight())) + + // Preserves the scrollTopRow when sdetached + element.remove() + expect(component.getScrollTopRow()).toBe(4) + expect(component.getScrollTop()).toBe(Math.round(4 * component.getLineHeight())) + + component.setScrollTopRow(6) + expect(component.getScrollTopRow()).toBe(6) + expect(component.getScrollTop()).toBe(Math.round(6 * component.getLineHeight())) + + jasmine.attachToDOM(element) + element.style.height = '60px' + expect(component.getScrollTopRow()).toBe(6) + expect(component.getScrollTop()).toBe(Math.round(6 * component.getLineHeight())) + }) + + it('allows the scrollLeft to be changed and queried in terms of base character columns via setScrollLeftColumn and getScrollLeftColumn', () => { + const {component, element} = buildComponent({attach: false, width: 80}) + + // Caches the scrollTopRow if we don't have measurements + component.setScrollLeftColumn(2) + expect(component.getScrollLeftColumn()).toBe(2) + + // Assigns the scrollTop based on the logical position when attached + jasmine.attachToDOM(element) + expect(component.getScrollLeft()).toBe(Math.round(2 * component.getBaseCharacterWidth())) + + // Allows the scrollTopRow to be updated while attached + component.setScrollLeftColumn(4) + expect(component.getScrollLeft()).toBe(Math.round(4 * component.getBaseCharacterWidth())) + + // Preserves the scrollTopRow when detached + element.remove() + expect(component.getScrollLeft()).toBe(Math.round(4 * component.getBaseCharacterWidth())) + + component.setScrollLeftColumn(6) + expect(component.getScrollLeft()).toBe(Math.round(6 * component.getBaseCharacterWidth())) + + jasmine.attachToDOM(element) + element.style.width = '60px' + expect(component.getScrollLeft()).toBe(Math.round(6 * component.getBaseCharacterWidth())) + }) + }) + describe('line and line number decorations', () => { it('adds decoration classes on screen lines spanned by decorated markers', async () => { const {component, element, editor} = buildComponent({width: 435, attach: false}) diff --git a/spec/text-editor-spec.coffee b/spec/text-editor-spec.coffee index 96d897fb9a7..95a79f59e77 100644 --- a/spec/text-editor-spec.coffee +++ b/spec/text-editor-spec.coffee @@ -99,23 +99,34 @@ describe "TextEditor", -> expect(editor.getAutoWidth()).toBeFalsy() expect(editor.getShowCursorOnSelection()).toBeTruthy() - editor.update({autoHeight: true, autoWidth: true, showCursorOnSelection: false}) + element = editor.getElement() + element.setHeight(100) + element.setWidth(100) + jasmine.attachToDOM(element) + + editor.update({showCursorOnSelection: false}) editor.setSelectedBufferRange([[1, 2], [3, 4]]) editor.addSelectionForBufferRange([[5, 6], [7, 8]], reversed: true) - editor.firstVisibleScreenRow = 5 - editor.firstVisibleScreenColumn = 5 + editor.setScrollTopRow(3) + expect(editor.getScrollTopRow()).toBe(3) + editor.setScrollLeftColumn(4) + expect(editor.getScrollLeftColumn()).toBe(4) editor.foldBufferRow(4) expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() editor2 = editor.copy() + element2 = editor2.getElement() + element2.setHeight(100) + element2.setWidth(100) + jasmine.attachToDOM(element2) expect(editor2.id).not.toBe editor.id expect(editor2.getSelectedBufferRanges()).toEqual editor.getSelectedBufferRanges() expect(editor2.getSelections()[1].isReversed()).toBeTruthy() - expect(editor2.getFirstVisibleScreenRow()).toBe 5 - expect(editor2.getFirstVisibleScreenColumn()).toBe 5 + expect(editor2.getScrollTopRow()).toBe(3) + expect(editor2.getScrollLeftColumn()).toBe(4) expect(editor2.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor2.getAutoWidth()).toBeTruthy() - expect(editor2.getAutoHeight()).toBeTruthy() + expect(editor2.getAutoWidth()).toBe(false) + expect(editor2.getAutoHeight()).toBe(false) expect(editor2.getShowCursorOnSelection()).toBeFalsy() # editor2 can now diverge from its origin edit session diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 28d50443d42..322bfbec88c 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -118,6 +118,8 @@ class TextEditorComponent { highlights: new Map(), cursors: [] } + this.pendingScrollTopRow = this.props.initialScrollTopRow + this.pendingScrollLeftColumn = this.props.initialScrollLeftColumn this.measuredContent = false this.gutterContainerVnode = null @@ -1241,6 +1243,7 @@ class TextEditorComponent { if (!this.measurements) this.performInitialMeasurements() this.props.model.setVisible(true) this.updateSync() + this.flushPendingLogicalScrollPosition() } } @@ -1710,6 +1713,24 @@ class TextEditorComponent { this.scheduleUpdate() } + flushPendingLogicalScrollPosition () { + let changedScrollTop = false + if (this.pendingScrollTopRow > 0) { + changedScrollTop = this.setScrollTopRow(this.pendingScrollTopRow) + this.pendingScrollTopRow = null + } + + let changedScrollLeft = false + if (this.pendingScrollLeftColumn > 0) { + changedScrollLeft = this.setScrollLeftColumn(this.pendingScrollLeftColumn) + this.pendingScrollLeftColumn = null + } + + if (changedScrollTop || changedScrollLeft) { + this.updateSync() + } + } + autoscrollVertically () { const {screenRange, options} = this.pendingAutoscroll @@ -2313,10 +2334,6 @@ class TextEditorComponent { return Math.ceil(this.getRenderedRowCount() / this.getRowsPerTile()) } - setFirstVisibleRow (row) { - this.setScrollTop(this.pixelPositionBeforeBlocksForRow(row)) - } - getFirstVisibleRow () { if (this.measurements) { return this.rowForPixelPosition(this.getScrollTop()) @@ -2333,11 +2350,9 @@ class TextEditorComponent { } getFirstVisibleColumn () { - return Math.floor(this.getScrollLeft() / this.getBaseCharacterWidth()) - } - - setFirstVisibleColumn (column) { - this.setScrollLeft(column * this.getBaseCharacterWidth()) + if (this.measurements) { + return Math.floor(this.getScrollLeft() / this.getBaseCharacterWidth()) + } } getVisibleTileCount () { @@ -2374,7 +2389,6 @@ class TextEditorComponent { } getScrollLeft () { - // this.scrollLeft = Math.min(this.getMaxScrollLeft(), this.scrollLeft) return this.scrollLeft } @@ -2402,6 +2416,40 @@ class TextEditorComponent { return this.setScrollLeft(scrollRight - this.getScrollContainerClientWidth()) } + setScrollTopRow (scrollTopRow) { + if (this.measurements) { + return this.setScrollTop(this.pixelPositionBeforeBlocksForRow(scrollTopRow)) + } else { + this.pendingScrollTopRow = scrollTopRow + } + return false + } + + getScrollTopRow () { + if (this.measurements) { + return this.rowForPixelPosition(this.getScrollTop()) + } else { + return this.pendingScrollTopRow || 0 + } + } + + setScrollLeftColumn (scrollLeftColumn) { + if (this.measurements && this.getLongestLineWidth() != null) { + return this.setScrollLeft(scrollLeftColumn * this.getBaseCharacterWidth()) + } else { + this.pendingScrollLeftColumn = scrollLeftColumn + } + return false + } + + getScrollLeftColumn () { + if (this.measurements) { + return Math.floor(this.getScrollLeft() / this.getBaseCharacterWidth()) + } else { + return this.pendingScrollLeftColumn || 0 + } + } + // Ensure the spatial index is populated with rows that are currently // visible so we *at least* get the longest row in the visible range. populateVisibleRowRange () { diff --git a/src/text-editor.coffee b/src/text-editor.coffee index a97732e9639..a2ba2dc8500 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -143,7 +143,7 @@ class TextEditor extends Model super { - @softTabs, @firstVisibleScreenRow, @firstVisibleScreenColumn, initialLine, initialColumn, tabLength, + @softTabs, @initialScrollTopRow, @initialScrollLeftColumn, initialLine, initialColumn, tabLength, @softWrapped, @decorationManager, @selectionsMarkerLayer, @buffer, suppressCursorCreation, @mini, @placeholderText, lineNumberGutterVisible, @largeFileMode, @assert, grammar, @showInvisibles, @autoHeight, @autoWidth, @scrollPastEnd, @editorWidthInChars, @@ -153,8 +153,6 @@ class TextEditor extends Model } = params @assert ?= (condition) -> condition - @firstVisibleScreenRow ?= 0 - @firstVisibleScreenColumn ?= 0 @emitter = new Emitter @disposables = new CompositeDisposable @cursors = [] @@ -415,8 +413,8 @@ class TextEditor extends Model displayLayerId: @displayLayer.id selectionsMarkerLayerId: @selectionsMarkerLayer.id - firstVisibleScreenRow: @getFirstVisibleScreenRow() - firstVisibleScreenColumn: @getFirstVisibleScreenColumn() + initialScrollTopRow: @getScrollTopRow() + initialScrollLeftColumn: @getScrollLeftColumn() atomicSoftTabs: @displayLayer.atomicSoftTabs softWrapHangingIndentLength: @displayLayer.softWrapHangingIndent @@ -766,7 +764,8 @@ class TextEditor extends Model @buffer, selectionsMarkerLayer, softTabs, suppressCursorCreation: true, tabLength: @tokenizedBuffer.getTabLength(), - @firstVisibleScreenRow, @firstVisibleScreenColumn, + initialScrollTopRow: @getScrollTopRow(), + initialScrollLeftColumn: @getScrollLeftColumn(), @assert, displayLayer, grammar: @getGrammar(), @autoWidth, @autoHeight, @showCursorOnSelection }) @@ -3585,7 +3584,8 @@ class TextEditor extends Model TextEditorElement ?= require('./text-editor-element') new TextEditorComponent({ model: this, - updatedSynchronously: TextEditorElement.prototype.updatedSynchronously + updatedSynchronously: TextEditorElement.prototype.updatedSynchronously, + @initialScrollTopRow, @initialScrollLeftColumn }) @component.element @@ -3670,10 +3670,9 @@ class TextEditor extends Model Grim.deprecate("This is now a view method. Call TextEditorElement::getWidth instead.") @getElement().getWidth() - # Experimental: Scroll the editor such that the given screen row is at the - # top of the visible area. + # Use setScrollTopRow instead of this method setFirstVisibleScreenRow: (screenRow) -> - @getElement().component.setFirstVisibleRow(screenRow) + @setScrollTopRow(screenRow) getFirstVisibleScreenRow: -> @getElement().component.getFirstVisibleRow() @@ -3684,8 +3683,9 @@ class TextEditor extends Model getVisibleRowRange: -> [@getFirstVisibleScreenRow(), @getLastVisibleScreenRow()] + # Use setScrollLeftColumn instead of this method setFirstVisibleScreenColumn: (column) -> - @getElement().component.setFirstVisibleColumn(column) + @setScrollLeftColumn(column) getFirstVisibleScreenColumn: -> @getElement().component.getFirstVisibleColumn() @@ -3745,6 +3745,18 @@ class TextEditor extends Model @getElement().getMaxScrollTop() + getScrollTopRow: -> + @getElement().component.getScrollTopRow() + + setScrollTopRow: (scrollTopRow) -> + @getElement().component.setScrollTopRow(scrollTopRow) + + getScrollLeftColumn: -> + @getElement().component.getScrollLeftColumn() + + setScrollLeftColumn: (scrollLeftColumn) -> + @getElement().component.setScrollLeftColumn(scrollLeftColumn) + intersectsVisibleRowRange: (startRow, endRow) -> Grim.deprecate("This is now a view method. Call TextEditorElement::intersectsVisibleRowRange instead.") From 24e03ee4e69c08998c2e3dc75403b9ddcefff98c Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 19 Apr 2017 15:52:03 -0600 Subject: [PATCH 275/403] Fix pageUp/Down tests by using a real element --- spec/text-editor-spec.coffee | 12 ++++++++++-- src/text-editor-element.js | 4 ++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/spec/text-editor-spec.coffee b/spec/text-editor-spec.coffee index 95a79f59e77..3c2afc6abc0 100644 --- a/spec/text-editor-spec.coffee +++ b/spec/text-editor-spec.coffee @@ -5491,7 +5491,11 @@ describe "TextEditor", -> describe ".pageUp/Down()", -> it "moves the cursor down one page length", -> - editor.setRowsPerPage(5) + editor.update(autoHeight: false) + element = editor.getElement() + jasmine.attachToDOM(element) + element.style.height = element.component.getLineHeight() * 5 + 'px' + element.measureDimensions() expect(editor.getCursorBufferPosition().row).toBe 0 @@ -5509,7 +5513,11 @@ describe "TextEditor", -> describe ".selectPageUp/Down()", -> it "selects one screen height of text up or down", -> - editor.setRowsPerPage(5) + editor.update(autoHeight: false) + element = editor.getElement() + jasmine.attachToDOM(element) + element.style.height = element.component.getLineHeight() * 5 + 'px' + element.measureDimensions() expect(editor.getCursorBufferPosition().row).toBe 0 diff --git a/src/text-editor-element.js b/src/text-editor-element.js index f9f270c2df3..559910506a6 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -103,6 +103,10 @@ class TextEditorElement extends HTMLElement { return this.emitter.on('did-detach', callback) } + measureDimensions () { + this.getComponent().measureDimensions() + } + setWidth (width) { this.style.width = this.getComponent().getGutterContainerWidth() + width + 'px' } From c38da710aed09205d9f8e3ec66cc597e88ce22e0 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 19 Apr 2017 18:59:53 +0200 Subject: [PATCH 276/403] Don't remove non accented character from history, improve test coverage Unfortunately Chromium does not trigger a `compositionstart` before firing the text input event for the non accented character. Using `undo` to remove such character from the history is risky because it could be grouped with a previous change, thus making Atom undo too much. With this commit we simply keep the behavior master exhibits as of today. In the process of rewriting this code path, however, we fixed a bug that occurred when opening the accented character menu while holding another key, and improved test coverage as well by simulating the events the browser triggers. --- spec/text-editor-component-spec.js | 196 +++++++++++++++++++++++++++++ src/text-editor-component.js | 37 +++--- 2 files changed, 217 insertions(+), 16 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 79f2cf7ff07..a63921c91db 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -2400,6 +2400,202 @@ describe('TextEditorComponent', () => { }) }) + describe('keyboard input', () => { + it('handles inserted accented characters via the press-and-hold menu on macOS correctly', () => { + const {editor, component, element} = buildComponent({text: ''}) + editor.insertText('x') + editor.setCursorBufferPosition([0, 1]) + + // Simulate holding the A key to open the press-and-hold menu, + // then closing it via ESC. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didKeydown({code: 'Escape'}) + component.didKeyup({code: 'Escape'}) + expect(editor.getText()).toBe('xa') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xaa') + editor.undo() + expect(editor.getText()).toBe('x') + + // Simulate holding the A key to open the press-and-hold menu, + // then selecting an alternative by typing a number. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didKeydown({code: 'Digit2'}) + component.didKeyup({code: 'Digit2'}) + component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) + expect(editor.getText()).toBe('xá') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xáa') + editor.undo() + expect(editor.getText()).toBe('x') + + // Simulate holding the A key to open the press-and-hold menu, + // then selecting an alternative by clicking on it. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) + expect(editor.getText()).toBe('xá') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xáa') + editor.undo() + expect(editor.getText()).toBe('x') + + // Simulate holding the A key to open the press-and-hold menu, + // cycling through the alternatives with the arrows, then selecting one of them with Enter. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionStart({data: ''}) + component.didCompositionUpdate({data: 'à'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xà') + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionUpdate({data: 'á'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xá') + component.didKeydown({code: 'Enter'}) + component.didCompositionUpdate({data: 'á'}) + component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) + component.didCompositionEnd({data: 'á', target: component.refs.hiddenInput}) + component.didKeyup({code: 'Enter'}) + expect(editor.getText()).toBe('xá') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xáa') + editor.undo() + expect(editor.getText()).toBe('x') + + // Simulate holding the A key to open the press-and-hold menu, + // cycling through the alternatives with the arrows, then closing it via ESC. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionStart({data: ''}) + component.didCompositionUpdate({data: 'à'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xà') + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionUpdate({data: 'á'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xá') + component.didKeydown({code: 'Escape'}) + component.didCompositionUpdate({data: 'a'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didCompositionEnd({data: 'a', target: component.refs.hiddenInput}) + component.didKeyup({code: 'Escape'}) + expect(editor.getText()).toBe('xa') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xaa') + editor.undo() + expect(editor.getText()).toBe('x') + + // Simulate pressing the O key and holding the A key to open the press-and-hold menu right before releasing the O key, + // cycling through the alternatives with the arrows, then closing it via ESC. + component.didKeydown({code: 'KeyO'}) + component.didKeypress({code: 'KeyO'}) + component.didTextInput({data: 'o', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyO'}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionStart({data: ''}) + component.didCompositionUpdate({data: 'à'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xoà') + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionUpdate({data: 'á'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xoá') + component.didKeydown({code: 'Escape'}) + component.didCompositionUpdate({data: 'a'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didCompositionEnd({data: 'a', target: component.refs.hiddenInput}) + component.didKeyup({code: 'Escape'}) + expect(editor.getText()).toBe('xoa') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + editor.undo() + expect(editor.getText()).toBe('x') + + // Simulate holding the A key to open the press-and-hold menu, + // cycling through the alternatives with the arrows, then closing it by changing focus. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionStart({data: ''}) + component.didCompositionUpdate({data: 'à'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xà') + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionUpdate({data: 'á'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xá') + component.didCompositionUpdate({data: 'á'}) + component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) + component.didCompositionEnd({data: 'á', target: component.refs.hiddenInput}) + expect(editor.getText()).toBe('xá') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xáa') + editor.undo() + expect(editor.getText()).toBe('x') + }) + }) + describe('styling changes', () => { it('updates the rendered content based on new measurements when the font dimensions change', async () => { const {component, element, editor} = buildComponent({rowsPerTile: 1, autoHeight: false}) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 322bfbec88c..a8367d23aae 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1393,10 +1393,12 @@ class TextEditorComponent { this.compositionCheckpoint = null } - // Undo insertion of the original non-accented character so it is discarded - // from the history and does not reappear on undo + // If the input event is fired while the accented character menu is open it + // means that the user has chosen one of the accented alternatives. Thus, we + // will replace the original non accented character with the selected + // alternative. if (this.accentedCharacterMenuIsOpen) { - this.props.model.undo() + this.props.model.selectLeft() } this.props.model.insertText(event.data, {groupUndo: true}) @@ -1413,24 +1415,24 @@ class TextEditorComponent { // before observing any keyup event, we observe events in the following // sequence: // - // keydown(keyCode: X), keypress, keydown(keyCode: X) + // keydown(code: X), keypress, keydown(code: X) // - // The keyCode X must be the same in the keydown events that bracket the + // The code X must be the same in the keydown events that bracket the // keypress, meaning we're *holding* the _same_ key we intially pressed. // Got that? didKeydown (event) { if (this.lastKeydownBeforeKeypress != null) { - if (this.lastKeydownBeforeKeypress.keyCode === event.keyCode) { + if (this.lastKeydownBeforeKeypress.code === event.code) { this.accentedCharacterMenuIsOpen = true - this.props.model.selectLeft() } + this.lastKeydownBeforeKeypress = null - } else { - this.lastKeydown = event } + + this.lastKeydown = event } - didKeypress () { + didKeypress (event) { this.lastKeydownBeforeKeypress = this.lastKeydown this.lastKeydown = null @@ -1439,9 +1441,11 @@ class TextEditorComponent { this.accentedCharacterMenuIsOpen = false } - didKeyup () { - this.lastKeydownBeforeKeypress = null - this.lastKeydown = null + didKeyup (event) { + if (this.lastKeydownBeforeKeypress && this.lastKeydownBeforeKeypress.code === event.code) { + this.lastKeydownBeforeKeypress = null + this.lastKeydown = null + } } // The IME composition events work like this: @@ -1451,13 +1455,14 @@ class TextEditorComponent { // 2. compositionupdate fired; event.data == 's' // User hits arrow keys to move around in completion helper // 3. compositionupdate fired; event.data == 's' for each arry key press - // User escape to cancel - // 4. compositionend fired - // OR User chooses a completion + // User escape to cancel OR User chooses a completion // 4. compositionend fired // 5. textInput fired; event.data == the completion string didCompositionStart () { this.compositionCheckpoint = this.props.model.createCheckpoint() + if (this.accentedCharacterMenuIsOpen) { + this.props.model.selectLeft() + } } didCompositionUpdate (event) { From 348d0858806c1e57cd84e8e709d6b9782f69b8e8 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 19 Apr 2017 16:23:49 -0600 Subject: [PATCH 277/403] Only enable cursor blink optimization when updateSync is using scheduler This ensures that direct calls to updateSync from places like scroll handlers never take this optimization path. --- src/text-editor-component.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index a8367d23aae..cb7c22311f8 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -181,7 +181,7 @@ class TextEditorComponent { const onlyBlinkingCursors = this.nextUpdateOnlyBlinksCursors this.nextUpdateOnlyBlinksCursors = null - if (onlyBlinkingCursors) { + if (useScheduler && onlyBlinkingCursors) { this.updateCursorBlinkSync() if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise() return From dfe647d914a649d85479d7ddec8ccd791cd0e65b Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 19 Apr 2017 16:38:52 -0600 Subject: [PATCH 278/403] Fix lint error --- src/text-editor-element.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/text-editor-element.js b/src/text-editor-element.js index 559910506a6..f87ca084508 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -85,9 +85,7 @@ class TextEditorElement extends HTMLElement { } updateModelFromAttributes () { - const props = { - mini: this.hasAttribute('mini'), - } + const props = {mini: this.hasAttribute('mini')} if (this.hasAttribute('placeholder-text')) props.placeholderText = this.getAttribute('placeholder-text') if (this.hasAttribute('gutter-hidden')) props.lineNumberGutterVisible = false From b242f034b4064d37e32e684fa5acc301a0cca927 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 19 Apr 2017 16:46:17 -0600 Subject: [PATCH 279/403] Don't render decorations for invalidated markers --- spec/text-editor-component-spec.js | 13 +++++++++++++ src/decoration-manager.js | 1 + 2 files changed, 14 insertions(+) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index a63921c91db..832456b2707 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -934,6 +934,19 @@ describe('TextEditorComponent', () => { expect(lineNodeForScreenRow(component, 2).classList.contains('b')).toBe(true) expect(lineNodeForScreenRow(component, 3).classList.contains('b')).toBe(true) }) + + it('does not decorate invalidated markers', async () => { + const {component, element, editor} = buildComponent() + const marker = editor.markScreenRange([[1, 0], [3, 0]], {invalidate: 'touch'}) + editor.decorateMarker(marker, {type: ['line', 'line-number'], class: 'a'}) + await component.getNextUpdatePromise() + expect(lineNodeForScreenRow(component, 2).classList.contains('a')).toBe(true) + + editor.getBuffer().insert([2, 0], 'x') + expect(marker.isValid()).toBe(false) + await component.getNextUpdatePromise() + expect(lineNodeForScreenRow(component, 2).classList.contains('a')).toBe(false) + }) }) describe('highlight decorations', () => { diff --git a/src/decoration-manager.js b/src/decoration-manager.js index 06dd3f2f5c0..e98731623b7 100644 --- a/src/decoration-manager.js +++ b/src/decoration-manager.js @@ -94,6 +94,7 @@ class DecorationManager { for (let i = 0; i < markers.length; i++) { const marker = markers[i] + if (!marker.isValid()) continue let decorationPropertiesForMarker = decorationPropertiesByMarker.get(marker) if (decorationPropertiesForMarker == null) { From 2f356f85d3fa84e354f505f8887cba953e51cc3c Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 20 Apr 2017 15:08:17 +0200 Subject: [PATCH 280/403] Make process.platform easier to mock --- spec/text-editor-component-spec.js | 11 ++++------- src/text-editor-component.js | 14 +++++--------- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 832456b2707..3f9282819bf 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1807,10 +1807,7 @@ describe('TextEditorComponent', () => { }) it('adds or removes cursors when holding cmd or ctrl when single-clicking', () => { - const {component, editor} = buildComponent() - spyOn(component, 'getPlatform').andCallFake(() => mockedPlatform) - - let mockedPlatform = 'darwin' + const {component, editor} = buildComponent({platform: 'darwin'}) expect(editor.getCursorScreenPositions()).toEqual([[0, 0]]) // add cursor at 1, 16 @@ -1870,9 +1867,8 @@ describe('TextEditorComponent', () => { ) expect(editor.getCursorScreenPositions()).toEqual([[1, 4]]) - mockedPlatform = 'win32' - // ctrl-click adds cursors on platforms *other* than macOS + component.props.platform = 'win32' component.didMouseDownOnContent( Object.assign(clientPositionForCharacter(component, 1, 16), { detail: 1, @@ -2823,7 +2819,8 @@ function buildComponent (params = {}) { const component = new TextEditorComponent({ model: editor, rowsPerTile: params.rowsPerTile, - updatedSynchronously: false + updatedSynchronously: false, + platform: params.platform }) const {element} = component if (!editor.getAutoHeight()) { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index cb7c22311f8..dd622939cd6 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -823,10 +823,6 @@ class TextEditorComponent { ) } - getPlatform () { - return process.platform - } - queryScreenLinesToRender () { const {model} = this.props @@ -1474,12 +1470,12 @@ class TextEditorComponent { } didMouseDownOnContent (event) { - const {model} = this.props + const {model, platform} = this.props const {target, button, detail, ctrlKey, shiftKey, metaKey} = event // Only handle mousedown events for left mouse button (or the middle mouse // button on Linux where it pastes the selection clipboard). - if (!(button === 0 || (this.getPlatform() === 'linux' && button === 1))) return + if (!(button === 0 || (platform === 'linux' && button === 1))) return const screenPosition = this.screenPositionForMouseEvent(event) @@ -1489,7 +1485,7 @@ class TextEditorComponent { return } - const addOrRemoveSelection = metaKey || (ctrlKey && this.getPlatform() !== 'darwin') + const addOrRemoveSelection = metaKey || (ctrlKey && platform !== 'darwin') switch (detail) { case 1: @@ -1534,7 +1530,7 @@ class TextEditorComponent { } didMouseDownOnLineNumberGutter (event) { - const {model} = this.props + const {model, platform} = this.props const {target, button, ctrlKey, shiftKey, metaKey} = event // Only handle mousedown events for left mouse button @@ -1548,7 +1544,7 @@ class TextEditorComponent { return } - const addOrRemoveSelection = metaKey || (ctrlKey && this.getPlatform() !== 'darwin') + const addOrRemoveSelection = metaKey || (ctrlKey && platform !== 'darwin') const endBufferRow = model.bufferPositionForScreenPosition([clickedScreenRow, Infinity]).row const clickedLineBufferRange = Range(Point(startBufferRow, 0), Point(endBufferRow + 1, 0)) From 1e6a1c61e7dbd7722419c346e07d9fe691dac033 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 20 Apr 2017 15:34:48 +0200 Subject: [PATCH 281/403] Add middle mouse pasting on Linux --- spec/text-editor-component-spec.js | 40 ++++++++++++++++++++++++++++++ src/text-editor-component.js | 36 ++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 3f9282819bf..1f2f05d6c11 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -7,6 +7,8 @@ const TextBuffer = require('text-buffer') const fs = require('fs') const path = require('path') const Grim = require('grim') +const electron = require('electron') +const clipboard = require('../src/safe-clipboard') const SAMPLE_TEXT = fs.readFileSync(path.join(__dirname, 'fixtures', 'sample.js'), 'utf8') const NBSP_CHARACTER = '\u00a0' @@ -2127,6 +2129,44 @@ describe('TextEditorComponent', () => { expect(component.getScrollTop()).toBe(maxScrollTop) expect(component.getScrollLeft()).toBe(maxScrollLeft) }) + + it('pastes the previously selected text when clicking the middle mouse button on Linux', async () => { + spyOn(electron.ipcRenderer, 'send').andCallFake(function (eventName, selectedText) { + if (eventName === 'write-text-to-selection-clipboard') { + clipboard.writeText(selectedText, 'selection') + } + }) + + const {component, editor} = buildComponent({platform: 'linux'}) + + // Middle mouse pasting. + editor.setSelectedBufferRange([[1, 6], [1, 10]]) + await conditionPromise(() => TextEditor.clipboard.read() === 'sort') + component.didMouseDownOnContent({ + button: 1, + clientX: clientLeftForCharacter(component, 10, 0), + clientY: clientTopForLine(component, 10) + }) + expect(TextEditor.clipboard.read()).toBe('sort') + expect(editor.lineTextForBufferRow(10)).toBe('sort') + editor.undo() + + // Ensure left clicks don't interfere. + editor.setSelectedBufferRange([[1, 2], [1, 5]]) + await conditionPromise(() => TextEditor.clipboard.read() === 'var') + component.didMouseDownOnContent({ + button: 0, + detail: 1, + clientX: clientLeftForCharacter(component, 10, 0), + clientY: clientTopForLine(component, 10) + }) + component.didMouseDownOnContent({ + button: 1, + clientX: clientLeftForCharacter(component, 10, 0), + clientY: clientTopForLine(component, 10) + }) + expect(editor.lineTextForBufferRow(10)).toBe('var') + }) }) describe('on the line number gutter', () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index dd622939cd6..bdcc88b9b30 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -6,6 +6,8 @@ const {Point, Range} = require('text-buffer') const LineTopIndex = require('line-top-index') const TextEditor = require('./text-editor') const {isPairedCharacter} = require('./text-utils') +const clipboard = require('./safe-clipboard') +const electron = require('electron') const $ = etch.dom let TextEditorElement @@ -1485,6 +1487,14 @@ class TextEditorComponent { return } + // Handle middle mouse button only on Linux (paste clipboard) + if (platform === 'linux' && button === 1) { + const selection = clipboard.readText('selection') + model.setCursorScreenPosition(screenPosition, {autoscroll: false}) + model.insertText(selection) + return + } + const addOrRemoveSelection = metaKey || (ctrlKey && platform !== 'darwin') switch (detail) { @@ -2101,7 +2111,7 @@ class TextEditorComponent { } observeModel () { - const {model} = this.props + const {model, platform} = this.props model.component = this const scheduleUpdate = this.scheduleUpdate.bind(this) this.disposables.add(model.displayLayer.onDidReset(() => { @@ -2128,6 +2138,30 @@ class TextEditorComponent { this.disposables.add(model.observeDecorations((decoration) => { if (decoration.getProperties().type === 'block') this.observeBlockDecoration(decoration) })) + + if (platform === 'linux') { + let immediateId = null + + this.disposables.add(model.onDidChangeSelectionRange(() => { + if (immediateId) { + clearImmediate(immediateId) + } + + immediateId = setImmediate(() => { + immediateId = null + + if (model.isDestroyed()) return + + const selectedText = model.getSelectedText() + if (selectedText) { + // This uses ipcRenderer.send instead of clipboard.writeText because + // clipboard.writeText is a sync ipcRenderer call on Linux and that + // will slow down selections. + electron.ipcRenderer.send('write-text-to-selection-clipboard', selectedText) + } + }) + })) + } } observeBlockDecoration (decoration) { From 9d79b0189fb4c31df647fd4eb58d5552ee483e17 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 20 Apr 2017 17:16:08 +0200 Subject: [PATCH 282/403] Fix cursor positioning around fold markers --- spec/text-editor-component-spec.js | 24 ++++++++++++++++++++++-- src/text-editor-component.js | 24 +++++++++++++++++++----- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 1f2f05d6c11..9bb8bd646ed 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -331,6 +331,20 @@ describe('TextEditorComponent', () => { expect(element.querySelector('.cursor').offsetWidth).toBe(Math.round(component.getBaseCharacterWidth())) }) + it('positions and sizes cursors correctly when they are located next to a fold marker', async () => { + const {component, element, editor} = buildComponent() + editor.foldBufferRange([[0, 3], [0, 6]]) + + editor.setCursorScreenPosition([0, 3]) + await component.getNextUpdatePromise() + const cursor = element.querySelector('.cursor') + verifyCursorPosition(component, element.querySelector('.cursor'), 0, 3) + + editor.setCursorScreenPosition([0, 4]) + await component.getNextUpdatePromise() + verifyCursorPosition(component, element.querySelector('.cursor'), 0, 4) + }) + it('places the hidden input element at the location of the last cursor if it is visible', async () => { const {component, element, editor} = buildComponent({height: 60, width: 120, rowsPerTile: 2}) const {hiddenInput} = component.refs @@ -2893,7 +2907,7 @@ async function setEditorWidthInCharacters (component, widthInCharacters) { function verifyCursorPosition (component, cursorNode, row, column) { const rect = cursorNode.getBoundingClientRect() expect(Math.round(rect.top)).toBe(clientTopForLine(component, row)) - expect(Math.round(rect.left)).toBe(clientLeftForCharacter(component, row, column)) + expect(Math.round(rect.left)).toBe(Math.round(clientLeftForCharacter(component, row, column))) } function clientTopForLine (component, row) { @@ -2905,7 +2919,7 @@ function clientLeftForCharacter (component, row, column) { let textNodeStartColumn = 0 for (const textNode of textNodes) { const textNodeEndColumn = textNodeStartColumn + textNode.textContent.length - if (column <= textNodeEndColumn) { + if (column < textNodeEndColumn) { const range = document.createRange() range.setStart(textNode, column - textNodeStartColumn) range.setEnd(textNode, column - textNodeStartColumn) @@ -2913,6 +2927,12 @@ function clientLeftForCharacter (component, row, column) { } textNodeStartColumn = textNodeEndColumn } + + const lastTextNode = textNodes[textNodes.length - 1] + const range = document.createRange() + range.setStart(lastTextNode, 0) + range.setEnd(lastTextNode, lastTextNode.textContent.length) + return range.getBoundingClientRect().right } function clientPositionForCharacter (component, row, column) { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index bdcc88b9b30..5f66f5f513b 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1981,30 +1981,33 @@ class TextEditorComponent { let lineNodeClientLeft = -1 let textNodeStartColumn = 0 let textNodesIndex = 0 + let lastTextNodeRight = null columnLoop: // eslint-disable-line no-labels for (let columnsIndex = 0; columnsIndex < columnsToMeasure.length; columnsIndex++) { + const nextColumnToMeasure = columnsToMeasure[columnsIndex] while (textNodesIndex < textNodes.length) { - const nextColumnToMeasure = columnsToMeasure[columnsIndex] if (nextColumnToMeasure === 0) { positions.set(0, 0) continue columnLoop // eslint-disable-line no-labels } - if (nextColumnToMeasure >= lineNode.textContent.length) { - } if (positions.has(nextColumnToMeasure)) continue columnLoop // eslint-disable-line no-labels const textNode = textNodes[textNodesIndex] const textNodeEndColumn = textNodeStartColumn + textNode.textContent.length - if (nextColumnToMeasure <= textNodeEndColumn) { + if (nextColumnToMeasure < textNodeEndColumn) { let clientPixelPosition if (nextColumnToMeasure === textNodeStartColumn) { clientPixelPosition = clientRectForRange(textNode, 0, 1).left } else { clientPixelPosition = clientRectForRange(textNode, 0, nextColumnToMeasure - textNodeStartColumn).right } - if (lineNodeClientLeft === -1) lineNodeClientLeft = lineNode.getBoundingClientRect().left + + if (lineNodeClientLeft === -1) { + lineNodeClientLeft = lineNode.getBoundingClientRect().left + } + positions.set(nextColumnToMeasure, Math.round(clientPixelPosition - lineNodeClientLeft)) continue columnLoop // eslint-disable-line no-labels } else { @@ -2012,6 +2015,17 @@ class TextEditorComponent { textNodeStartColumn = textNodeEndColumn } } + + if (lastTextNodeRight == null) { + const lastTextNode = textNodes[textNodes.length - 1] + lastTextNodeRight = clientRectForRange(lastTextNode, 0, lastTextNode.textContent.length).right + } + + if (lineNodeClientLeft === -1) { + lineNodeClientLeft = lineNode.getBoundingClientRect().left + } + + positions.set(nextColumnToMeasure, lastTextNodeRight - lineNodeClientLeft) } } From e4659aad875ec6545dddc40319a4a98d7581cf1d Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 20 Apr 2017 19:05:14 +0200 Subject: [PATCH 283/403] Add data-screen-row to line nodes --- spec/text-editor-component-spec.js | 6 +++++ src/text-editor-component.js | 35 ++++++++++++++++++++++++------ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 9bb8bd646ed..bf9c20c6e9d 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -48,6 +48,9 @@ describe('TextEditorComponent', () => { expect(Array.from(element.querySelectorAll('.line-number:not(.dummy)')).map(element => element.textContent.trim())).toEqual([ '10', '11', '12', '4', '5', '6', '7', '8', '9' ]) + expect(Array.from(element.querySelectorAll('.line:not(.dummy)')).map(element => element.dataset.screenRow)).toEqual([ + '9', '10', '11', '3', '4', '5', '6', '7', '8' + ]) expect(Array.from(element.querySelectorAll('.line:not(.dummy)')).map(element => element.textContent)).toEqual([ editor.lineTextForScreenRow(9), ' ', // this line is blank in the model, but we render a space to prevent the line from collapsing vertically @@ -64,6 +67,9 @@ describe('TextEditorComponent', () => { expect(Array.from(element.querySelectorAll('.line-number:not(.dummy)')).map(element => element.textContent.trim())).toEqual([ '1', '2', '3', '4', '5', '6', '7', '8', '9' ]) + expect(Array.from(element.querySelectorAll('.line:not(.dummy)')).map(element => element.dataset.screenRow)).toEqual([ + '0', '1', '2', '3', '4', '5', '6', '7', '8' + ]) expect(Array.from(element.querySelectorAll('.line:not(.dummy)')).map(element => element.textContent)).toEqual([ editor.lineTextForScreenRow(0), editor.lineTextForScreenRow(1), diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 5f66f5f513b..0990d4c20c5 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -594,11 +594,12 @@ class TextEditorComponent { } if (this.extraLinesToMeasure) { - this.extraLinesToMeasure.forEach((screenLine, row) => { - if (row < startRow || row >= endRow) { + this.extraLinesToMeasure.forEach((screenLine, screenRow) => { + if (screenRow < startRow || screenRow >= endRow) { tileNodes.push($(LineComponent, { key: 'extra-' + screenLine.id, screenLine, + screenRow, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId @@ -2931,7 +2932,7 @@ class LinesTileComponent { renderLines () { const { measuredContent, height, width, - screenLines, lineDecorations, blockDecorations, displayLayer, + tileStartRow, screenLines, lineDecorations, blockDecorations, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId } = this.props @@ -2939,6 +2940,7 @@ class LinesTileComponent { this.linesVnode = $(LinesComponent, { height, width, + tileStartRow, screenLines, lineDecorations, blockDecorations, @@ -2957,6 +2959,8 @@ class LinesTileComponent { if (oldProps.height !== newProps.height) return true if (oldProps.width !== newProps.width) return true if (oldProps.lineHeight !== newProps.lineHeight) return true + if (oldProps.tileStartRow !== newProps.tileStartRow) return true + if (oldProps.tileEndRow !== newProps.tileEndRow) return true if (!arraysEqual(oldProps.screenLines, newProps.screenLines)) return true if (!arraysEqual(oldProps.lineDecorations, newProps.lineDecorations)) return true @@ -3013,7 +3017,7 @@ class LinesComponent { constructor (props) { this.props = {} const { - width, height, + width, height, tileStartRow, screenLines, lineDecorations, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId } = props @@ -3028,6 +3032,7 @@ class LinesComponent { for (let i = 0, length = screenLines.length; i < length; i++) { const component = new LineComponent({ screenLine: screenLines[i], + screenRow: tileStartRow + i, lineDecoration: lineDecorations[i], displayLayer, lineNodesByScreenLineId, @@ -3065,7 +3070,7 @@ class LinesComponent { updateLines (props) { var { - screenLines, lineDecorations, + screenLines, tileStartRow, lineDecorations, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId } = props @@ -3084,6 +3089,7 @@ class LinesComponent { if (oldScreenLineIndex >= oldScreenLinesEndIndex) { var newScreenLineComponent = new LineComponent({ screenLine: newScreenLine, + screenRow: tileStartRow + newScreenLineIndex, lineDecoration: lineDecorations[newScreenLineIndex], displayLayer, lineNodesByScreenLineId, @@ -3101,7 +3107,10 @@ class LinesComponent { oldScreenLineIndex++ } else if (oldScreenLine === newScreenLine) { var lineComponent = this.lineComponents[lineComponentIndex] - lineComponent.update({lineDecoration: lineDecorations[newScreenLineIndex]}) + lineComponent.update({ + screenRow: tileStartRow + newScreenLineIndex, + lineDecoration: lineDecorations[newScreenLineIndex] + }) oldScreenLineIndex++ newScreenLineIndex++ @@ -3114,6 +3123,7 @@ class LinesComponent { while (newScreenLineIndex < oldScreenLineIndexInNewScreenLines) { var newScreenLineComponent = new LineComponent({ // eslint-disable-line no-redeclare screenLine: newScreenLines[newScreenLineIndex], + screenRow: tileStartRow + newScreenLineIndex, lineDecoration: lineDecorations[newScreenLineIndex], displayLayer, lineNodesByScreenLineId, @@ -3138,6 +3148,7 @@ class LinesComponent { var oldScreenLineComponent = this.lineComponents[lineComponentIndex] var newScreenLineComponent = new LineComponent({ // eslint-disable-line no-redeclare screenLine: newScreenLines[newScreenLineIndex], + screenRow: tileStartRow + newScreenLineIndex, lineDecoration: lineDecorations[newScreenLineIndex], displayLayer, lineNodesByScreenLineId, @@ -3224,10 +3235,15 @@ class LinesComponent { class LineComponent { constructor (props) { - const {displayLayer, screenLine, lineNodesByScreenLineId, textNodesByScreenLineId} = props + const { + displayLayer, + screenLine, screenRow, + lineNodesByScreenLineId, textNodesByScreenLineId + } = props this.props = props this.element = document.createElement('div') this.element.className = this.buildClassName() + this.element.dataset.screenRow = screenRow lineNodesByScreenLineId.set(screenLine.id, this.element) const textNodes = [] @@ -3278,6 +3294,11 @@ class LineComponent { this.props.lineDecoration = newProps.lineDecoration this.element.className = this.buildClassName() } + + if (this.props.screenRow !== newProps.screenRow) { + this.props.screenRow = newProps.screenRow + this.element.dataset.screenRow = newProps.screenRow + } } destroy () { From 3bca09bf272ee9d77da972cd85a2615063e0ae09 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 20 Apr 2017 19:47:03 +0200 Subject: [PATCH 284/403] Schedule update when setting scroll top row or scroll left column --- spec/text-editor-component-spec.js | 16 ++++++++++++++-- src/text-editor-component.js | 24 ++++++++++++++++-------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index bf9c20c6e9d..2d5c88edc3f 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -2837,28 +2837,40 @@ describe('TextEditorComponent', () => { await component.getNextUpdatePromise() expect(component.getMaxScrollTop() / component.getLineHeight()).toBe(9) + expect(component.refs.verticalScrollbar.element.scrollTop).toBe(0 * component.getLineHeight()) editor.setFirstVisibleScreenRow(1) expect(component.getFirstVisibleRow()).toBe(1) + await component.getNextUpdatePromise() + expect(component.refs.verticalScrollbar.element.scrollTop).toBe(1 * component.getLineHeight()) editor.setFirstVisibleScreenRow(5) expect(component.getFirstVisibleRow()).toBe(5) + await component.getNextUpdatePromise() + expect(component.refs.verticalScrollbar.element.scrollTop).toBe(5 * component.getLineHeight()) editor.setFirstVisibleScreenRow(11) expect(component.getFirstVisibleRow()).toBe(9) + await component.getNextUpdatePromise() + expect(component.refs.verticalScrollbar.element.scrollTop).toBe(9 * component.getLineHeight()) }) it('delegates setFirstVisibleScreenColumn and getFirstVisibleScreenColumn to the component', async () => { const {component, element, editor} = buildComponent({rowsPerTile: 3, autoHeight: false}) element.style.width = 30 * component.getBaseCharacterWidth() + 'px' await component.getNextUpdatePromise() - expect(editor.getFirstVisibleScreenColumn()).toBe(0) - component.setScrollLeft(5.5 * component.getBaseCharacterWidth()) + expect(component.refs.horizontalScrollbar.element.scrollLeft).toBe(0 * component.getBaseCharacterWidth()) + + setScrollLeft(component, 5.5 * component.getBaseCharacterWidth()) expect(editor.getFirstVisibleScreenColumn()).toBe(5) + await component.getNextUpdatePromise() + expect(component.refs.horizontalScrollbar.element.scrollLeft).toBe(Math.round(5.5 * component.getBaseCharacterWidth())) editor.setFirstVisibleScreenColumn(12) expect(component.getScrollLeft()).toBe(Math.round(12 * component.getBaseCharacterWidth())) + await component.getNextUpdatePromise() + expect(component.refs.horizontalScrollbar.element.scrollLeft).toBe(Math.round(12 * component.getBaseCharacterWidth())) }) }) }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 0990d4c20c5..88b52ddccd9 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1728,13 +1728,13 @@ class TextEditorComponent { flushPendingLogicalScrollPosition () { let changedScrollTop = false if (this.pendingScrollTopRow > 0) { - changedScrollTop = this.setScrollTopRow(this.pendingScrollTopRow) + changedScrollTop = this.setScrollTopRow(this.pendingScrollTopRow, false) this.pendingScrollTopRow = null } let changedScrollLeft = false if (this.pendingScrollLeftColumn > 0) { - changedScrollLeft = this.setScrollLeftColumn(this.pendingScrollLeftColumn) + changedScrollLeft = this.setScrollLeftColumn(this.pendingScrollLeftColumn, false) this.pendingScrollLeftColumn = null } @@ -2466,13 +2466,17 @@ class TextEditorComponent { return this.setScrollLeft(scrollRight - this.getScrollContainerClientWidth()) } - setScrollTopRow (scrollTopRow) { + setScrollTopRow (scrollTopRow, scheduleUpdate = true) { if (this.measurements) { - return this.setScrollTop(this.pixelPositionBeforeBlocksForRow(scrollTopRow)) + const didScroll = this.setScrollTop(this.pixelPositionBeforeBlocksForRow(scrollTopRow)) + if (didScroll && scheduleUpdate) { + this.scheduleUpdate() + } + return didScroll } else { this.pendingScrollTopRow = scrollTopRow + return false } - return false } getScrollTopRow () { @@ -2483,13 +2487,17 @@ class TextEditorComponent { } } - setScrollLeftColumn (scrollLeftColumn) { + setScrollLeftColumn (scrollLeftColumn, scheduleUpdate = true) { if (this.measurements && this.getLongestLineWidth() != null) { - return this.setScrollLeft(scrollLeftColumn * this.getBaseCharacterWidth()) + const didScroll = this.setScrollLeft(scrollLeftColumn * this.getBaseCharacterWidth()) + if (didScroll && scheduleUpdate) { + this.scheduleUpdate() + } + return didScroll } else { this.pendingScrollLeftColumn = scrollLeftColumn + return false } - return false } getScrollLeftColumn () { From 3d6921cca39bd2de03c0e6d5785dcdd66a6d6f89 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 20 Apr 2017 13:53:29 -0600 Subject: [PATCH 285/403] Add cursor decorations These decorations allow the class and style of a cursor associated with any marker to be customized. /cc @t9md --- spec/text-editor-component-spec.js | 35 +++++++++++++ src/text-editor-component.js | 79 ++++++++++++++++++++---------- src/text-editor.coffee | 8 +++ 3 files changed, 96 insertions(+), 26 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 2d5c88edc3f..31db6576092 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1734,6 +1734,41 @@ describe('TextEditorComponent', () => { } }) + describe('cursor decorations', () => { + it('allows default cursors to be customized', async () => { + const {component, element, editor} = buildComponent() + + editor.addCursorAtScreenPosition([1, 0]) + const [cursorMarker1, cursorMarker2] = editor.getCursors().map(c => c.getMarker()) + + editor.decorateMarker(cursorMarker1, {type: 'cursor', class: 'a'}) + editor.decorateMarker(cursorMarker2, {type: 'cursor', class: 'b', style: {visibility: 'hidden'}}) + editor.decorateMarker(cursorMarker2, {type: 'cursor', style: {backgroundColor: 'red'}}) + await component.getNextUpdatePromise() + + const cursorNodes = element.querySelectorAll('.cursor') + expect(cursorNodes.length).toBe(2) + + + expect(cursorNodes[0].className).toBe('cursor a') + expect(cursorNodes[1].className).toBe('cursor b') + expect(cursorNodes[1].style.visibility).toBe('hidden') + expect(cursorNodes[1].style.backgroundColor).toBe('red') + }) + + it('allows markers that are not actually associated with cursors to be decorated as if they were cursors', async () => { + const {component, element, editor} = buildComponent() + const marker = editor.markScreenPosition([1, 0]) + editor.decorateMarker(marker, {type: 'cursor', class: 'a'}) + await component.getNextUpdatePromise() + + const cursorNodes = element.querySelectorAll('.cursor') + expect(cursorNodes.length).toBe(2) + expect(cursorNodes[0].className).toBe('cursor') + expect(cursorNodes[1].className).toBe('cursor a') + }) + }) + describe('mouse input', () => { describe('on the lines', () => { it('positions the cursor on single-click', async () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 88b52ddccd9..46eb9b16463 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -118,7 +118,7 @@ class TextEditorComponent { } this.decorationsToMeasure = { highlights: new Map(), - cursors: [] + cursors: new Map() } this.pendingScrollTopRow = this.props.initialScrollTopRow this.pendingScrollLeftColumn = this.props.initialScrollLeftColumn @@ -630,14 +630,20 @@ class TextEditorComponent { const children = [this.renderHiddenInput()] for (let i = 0; i < this.decorationsToRender.cursors.length; i++) { - const {pixelLeft, pixelTop, pixelWidth} = this.decorationsToRender.cursors[i] + const {pixelLeft, pixelTop, pixelWidth, className: extraCursorClassName, style: extraCursorStyle} = this.decorationsToRender.cursors[i] + let cursorClassName = 'cursor' + if (extraCursorClassName) cursorClassName += ' ' + extraCursorClassName + + const cursorStyle = { + height: cursorHeight, + width: pixelWidth + 'px', + transform: `translate(${pixelLeft}px, ${pixelTop}px)` + } + if (extraCursorStyle) Object.assign(cursorStyle, extraCursorStyle) + children.push($.div({ - className: 'cursor', - style: { - height: cursorHeight, - width: pixelWidth + 'px', - transform: `translate(${pixelLeft}px, ${pixelTop}px)` - } + className: cursorClassName, + style: cursorStyle })) } @@ -922,7 +928,7 @@ class TextEditorComponent { this.decorationsToRender.customGutter.clear() this.decorationsToRender.blocks = new Map() this.decorationsToMeasure.highlights.clear() - this.decorationsToMeasure.cursors.length = 0 + this.decorationsToMeasure.cursors.clear() const decorationsByMarker = this.props.model.decorationManager.decorationPropertiesByMarkerForScreenRowRange( @@ -955,7 +961,7 @@ class TextEditorComponent { this.addHighlightDecorationToMeasure(decoration, screenRange, marker.id) break case 'cursor': - this.addCursorDecorationToMeasure(marker, screenRange, reversed) + this.addCursorDecorationToMeasure(decoration, marker, screenRange, reversed) break case 'overlay': this.addOverlayDecorationToRender(decoration, marker) @@ -1042,22 +1048,43 @@ class TextEditorComponent { } } - addCursorDecorationToMeasure (marker, screenRange, reversed) { + addCursorDecorationToMeasure (decoration, marker, screenRange, reversed) { const {model} = this.props if (!model.getShowCursorOnSelection() && !screenRange.isEmpty()) return - const isLastCursor = model.getLastCursor().getMarker() === marker - const screenPosition = reversed ? screenRange.start : screenRange.end - const {row, column} = screenPosition - if (row < this.getRenderedStartRow() || row >= this.getRenderedEndRow()) return + let decorationToMeasure = this.decorationsToMeasure.cursors.get(marker) + if (!decorationToMeasure) { + const isLastCursor = model.getLastCursor().getMarker() === marker + const screenPosition = reversed ? screenRange.start : screenRange.end + const {row, column} = screenPosition + + if (row < this.getRenderedStartRow() || row >= this.getRenderedEndRow()) return - this.requestHorizontalMeasurement(row, column) - let columnWidth = 0 - if (model.lineLengthForScreenRow(row) > column) { - columnWidth = 1 - this.requestHorizontalMeasurement(row, column + 1) + this.requestHorizontalMeasurement(row, column) + let columnWidth = 0 + if (model.lineLengthForScreenRow(row) > column) { + columnWidth = 1 + this.requestHorizontalMeasurement(row, column + 1) + } + decorationToMeasure = {screenPosition, columnWidth, isLastCursor} + this.decorationsToMeasure.cursors.set(marker, decorationToMeasure) + } + + if (decoration.class) { + if (decorationToMeasure.className) { + decorationToMeasure.className += ' ' + decoration.class + } else { + decorationToMeasure.className = decoration.class + } + } + + if (decoration.style) { + if (decorationToMeasure.style) { + Object.assign(decorationToMeasure.style, decoration.style) + } else { + decorationToMeasure.style = Object.assign({}, decoration.style) + } } - this.decorationsToMeasure.cursors.push({screenPosition, columnWidth, isLastCursor}) } addOverlayDecorationToRender (decoration, marker) { @@ -1131,8 +1158,8 @@ class TextEditorComponent { updateCursorsToRender () { this.decorationsToRender.cursors.length = 0 - for (let i = 0; i < this.decorationsToMeasure.cursors.length; i++) { - const cursor = this.decorationsToMeasure.cursors[i] + this.decorationsToMeasure.cursors.forEach((cursor) => { + const {screenPosition, className, style} = cursor const {row, column} = cursor.screenPosition const pixelTop = this.pixelPositionAfterBlocksForRow(row) @@ -1144,10 +1171,10 @@ class TextEditorComponent { pixelWidth = this.pixelLeftForRowAndColumn(row, column + 1) - pixelLeft } - const cursorPosition = {pixelTop, pixelLeft, pixelWidth} - this.decorationsToRender.cursors[i] = cursorPosition + const cursorPosition = {pixelTop, pixelLeft, pixelWidth, className, style} + this.decorationsToRender.cursors.push(cursorPosition) if (cursor.isLastCursor) this.hiddenInputPosition = cursorPosition - } + }) } updateOverlaysToRender () { diff --git a/src/text-editor.coffee b/src/text-editor.coffee index a2ba2dc8500..5330a564ce9 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -1778,8 +1778,16 @@ class TextEditor extends Model # * `block` Positions the view associated with the given item before or # after the row of the given `TextEditorMarker`, depending on the `position` # property. + # * `cursor` Renders a cursor at the head of the given marker. If multiple + # decorations are created for the same marker, their class strings and + # style objects are combined into a single cursor. You can use this + # decoration type to style existing cursors by passing in their markers + # or render artificial cursors that don't actually exist in the model + # by passing a marker that isn't actually associated with a cursor. # * `class` This CSS class will be applied to the decorated line number, # line, highlight, or overlay. + # * `style` An {Object} containing CSS style properties to apply to the + # relevant DOM node. Currently this only works with a `type` of `cursor`. # * `item` (optional) An {HTMLElement} or a model {Object} with a # corresponding view registered. Only applicable to the `gutter`, # `overlay` and `block` decoration types. From 1cc68e408ebc9bf7ee767182cd82fbe4f5b4f5c6 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 20 Apr 2017 15:14:35 -0600 Subject: [PATCH 286/403] Add TextEditorComponent.screenPositionForPixelPositionSync This method can be used to translate a pixel position to a screen position even if the line is not currently rendered on screen. --- spec/text-editor-component-spec.js | 37 ++++++++++++++++++++++++++++++ src/text-editor-component.js | 16 +++++++++++++ src/text-editor-element.js | 4 ++++ 3 files changed, 57 insertions(+) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 31db6576092..d832ab606c0 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -2830,6 +2830,43 @@ describe('TextEditorComponent', () => { }) }) + describe('screenPositionForPixelPositionSync', () => { + it('returns the screen position for the given pixel position, regardless of whether or not it is currently on screen', async () => { + const {component, element, editor} = buildComponent({rowsPerTile: 2, autoHeight: false}) + await setEditorHeightInLines(component, 3) + await setScrollTop(component, 3 * component.getLineHeight()) + const {component: referenceComponent} = buildComponent() + + { + const pixelPosition = referenceComponent.pixelPositionForScreenPositionSync({row: 0, column: 0}) + pixelPosition.top += component.getLineHeight() / 3 + pixelPosition.left += component.getBaseCharacterWidth() / 3 + expect(component.screenPositionForPixelPositionSync(pixelPosition)).toEqual([0, 0]) + } + + { + const pixelPosition = referenceComponent.pixelPositionForScreenPositionSync({row: 0, column: 5}) + pixelPosition.top += component.getLineHeight() / 3 + pixelPosition.left += component.getBaseCharacterWidth() / 3 + expect(component.screenPositionForPixelPositionSync(pixelPosition)).toEqual([0, 5]) + } + + { + const pixelPosition = referenceComponent.pixelPositionForScreenPositionSync({row: 5, column: 7}) + pixelPosition.top += component.getLineHeight() / 3 + pixelPosition.left += component.getBaseCharacterWidth() / 3 + expect(component.screenPositionForPixelPositionSync(pixelPosition)).toEqual([5, 7]) + } + + { + const pixelPosition = referenceComponent.pixelPositionForScreenPositionSync({row: 12, column: 1}) + pixelPosition.top += component.getLineHeight() / 3 + pixelPosition.left += component.getBaseCharacterWidth() / 3 + expect(component.screenPositionForPixelPositionSync(pixelPosition)).toEqual([12, 1]) + } + }) + }) + describe('model methods that delegate to the component / element', () => { it('delegates setHeight and getHeight to the component', async () => { const {component, element, editor} = buildComponent({autoHeight: false}) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 46eb9b16463..6bed15a521a 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -152,6 +152,22 @@ class TextEditorComponent { return {top, left} } + screenPositionForPixelPositionSync (pixelPosition) { + const {model} = this.props + + const row = Math.max(0, Math.min( + this.rowForPixelPosition(pixelPosition.top), + model.getApproximateScreenLineCount() - 1 + )) + + if (!this.renderedScreenLineForRow(row)) { + this.requestExtraLineToMeasure(row, model.screenLineForScreenRow(row)) + this.updateSyncBeforeMeasuringContent() + this.measureContentDuringUpdateSync() + } + return this.screenPositionForPixelPosition(pixelPosition) + } + scheduleUpdate (nextUpdateOnlyBlinksCursors = false) { if (!this.visible) return diff --git a/src/text-editor-element.js b/src/text-editor-element.js index f87ca084508..428c44ee5d4 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -201,6 +201,10 @@ class TextEditorElement extends HTMLElement { return this.getComponent().pixelPositionForScreenPositionSync(screenPosition) } + screenPositionForPixelPosition (pixelPosition) { + return this.getComponent().screenPositionForPixelPositionSync(pixelPosition) + } + getComponent () { if (!this.component) { this.component = new TextEditorComponent({ From 5bbbe1d790cfbac7d4249e8597122f20875b6e49 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 20 Apr 2017 23:00:38 -0600 Subject: [PATCH 287/403] Give line numbers the full width of the line number gutter --- spec/text-editor-component-spec.js | 12 +++++++++++- src/text-editor-component.js | 9 ++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index d832ab606c0..0f7164b9b3d 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -123,13 +123,23 @@ describe('TextEditorComponent', () => { for (const child of lineNumberGutterElement.children) { expect(child.offsetWidth).toBe(lineNumberGutterElement.offsetWidth) + if (!child.classList.contains('line-number')) { + for (const lineNumberElement of child.children) { + expect(lineNumberElement.offsetWidth).toBe(lineNumberGutterElement.offsetWidth) + } + } } - editor.setText('\n'.repeat(99)) + editor.setText('x\n'.repeat(99)) await component.getNextUpdatePromise() expect(lineNumberGutterElement.offsetHeight).toBe(component.getScrollHeight()) for (const child of lineNumberGutterElement.children) { expect(child.offsetWidth).toBe(lineNumberGutterElement.offsetWidth) + if (!child.classList.contains('line-number')) { + for (const lineNumberElement of child.children) { + expect(lineNumberElement.offsetWidth).toBe(lineNumberGutterElement.offsetWidth) + } + } } }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 6bed15a521a..829d319ae4f 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2721,13 +2721,16 @@ class LineNumberGutterComponent { let number = softWrapped ? '•' : bufferRow + 1 number = NBSP_CHARACTER.repeat(maxDigits - number.length) + number - let lineNumberProps = {key, className, dataset: {bufferRow}} - + const lineNumberProps = { + key, className, + style: {width: width + 'px'}, + dataset: {bufferRow} + } if (row === 0 || i > 0) { let currentRowTop = parentComponent.pixelPositionAfterBlocksForRow(row) let previousRowBottom = parentComponent.pixelPositionAfterBlocksForRow(row - 1) + lineHeight if (currentRowTop > previousRowBottom) { - lineNumberProps.style = {marginTop: (currentRowTop - previousRowBottom) + 'px'} + lineNumberProps.style.marginTop = (currentRowTop - previousRowBottom) + 'px' } } From 77f04c47d908b73579823a77bca4b9dd2af90099 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 20 Apr 2017 23:03:59 -0600 Subject: [PATCH 288/403] Consolidate editor style sheets --- static/atom.less | 1 - static/text-editor-light.less | 162 ---------------------------------- static/text-editor.less | 115 +++++++++++++++++++++++- 3 files changed, 114 insertions(+), 164 deletions(-) delete mode 100644 static/text-editor-light.less diff --git a/static/atom.less b/static/atom.less index 78bb8f2eab7..14e7def8fef 100644 --- a/static/atom.less +++ b/static/atom.less @@ -22,7 +22,6 @@ @import "panes"; @import "syntax"; @import "text-editor"; -@import "text-editor-light"; @import "title-bar"; @import "workspace-view"; diff --git a/static/text-editor-light.less b/static/text-editor-light.less deleted file mode 100644 index 493696acaf2..00000000000 --- a/static/text-editor-light.less +++ /dev/null @@ -1,162 +0,0 @@ -@import "ui-variables"; -@import "octicon-utf-codes"; -@import "octicon-mixins"; - -atom-text-editor { - display: flex; - font-family: Menlo, Consolas, 'DejaVu Sans Mono', monospace; - - // .editor--private, .editor-contents--private { - // height: 100%; - // width: 100%; - // background-color: inherit; - // } - // - // .editor-contents--private { - // width: 100%; - // cursor: text; - // display: flex; - // -webkit-user-select: none; - // position: relative; - // } - // - // .gutter-container { - // background-color: inherit; - // } - - .gutter { - overflow: hidden; - z-index: 0; - text-align: right; - cursor: default; - min-width: 1em; - box-sizing: border-box; - background-color: inherit; - } - // - // .line-numbers { - // position: relative; - // background-color: inherit; - // } - - // .line-number { - // position: relative; - // // white-space: nowrap; - // padding-left: .5em; - // opacity: 0.6; - // - // &.cursor-line { - // opacity: 1; - // } - // - // .icon-right { - // .octicon(chevron-down, 0.8em); - // display: inline-block; - // visibility: hidden; - // opacity: .6; - // padding: 0 .4em; - // - // &::before { - // text-align: center; - // } - // } - // } - - .gutter:hover { - .line-number.foldable .icon-right { - visibility: visible; - - &:hover { - opacity: 1; - } - } - } - - .gutter, .gutter:hover { - .line-number.folded .icon-right { - .octicon(chevron-right, 0.8em); - - visibility: visible; - - &::before { - position: relative; - left: -.1em; - } - } - } - - .highlight { - background: none; - padding: 0; - } - - .highlight .region { - position: absolute; - pointer-events: none; - z-index: -1; - } - - .line { - white-space: pre; - - &.cursor-line .fold-marker::after { - opacity: 1; - } - } - - .fold-marker { - cursor: default; - - &::after { - .icon(0.8em, inline); - - content: @ellipsis; - padding-left: 0.2em; - } - } - - .placeholder-text { - position: absolute; - color: @text-color-subtle; - } - - .invisible-character { - font-weight: normal !important; - font-style: normal !important; - } - - .indent-guide { - display: inline-block; - box-shadow: inset 1px 0; - } - - .cursor { - z-index: 4; - pointer-events: none; - box-sizing: border-box; - position: absolute; - border-left: 1px solid; - opacity: 0; - } - - &.is-focused .cursor { - opacity: 1; - } - - .cursors.blink-off .cursor { - opacity: 0; - } -} - -atom-text-editor[mini] { - font-size: @input-font-size; - line-height: @component-line-height; - max-height: @component-line-height + 2; // +2 for borders - overflow: auto; -} - -atom-overlay { - position: fixed; - display: block; - z-index: 4; -} diff --git a/static/text-editor.less b/static/text-editor.less index ed3798d4029..ae0f564fae1 100644 --- a/static/text-editor.less +++ b/static/text-editor.less @@ -1,12 +1,50 @@ -@import "octicon-mixins.less"; +@import "ui-variables"; +@import "octicon-utf-codes"; +@import "octicon-mixins"; atom-text-editor { + display: flex; + font-family: Menlo, Consolas, 'DejaVu Sans Mono', monospace; + .gutter-container { float: left; width: min-content; background-color: inherit; } + .gutter { + overflow: hidden; + z-index: 0; + text-align: right; + cursor: default; + min-width: 1em; + box-sizing: border-box; + background-color: inherit; + } + + .gutter:hover { + .line-number.foldable .icon-right { + visibility: visible; + + &:hover { + opacity: 1; + } + } + } + + .gutter, .gutter:hover { + .line-number.folded .icon-right { + .octicon(chevron-right, 0.8em); + + visibility: visible; + + &::before { + position: relative; + left: -.1em; + } + } + } + .line-numbers { width: max-content; background-color: inherit; @@ -40,4 +78,79 @@ atom-text-editor { will-change: transform; overflow: hidden; } + + .highlight { + background: none; + padding: 0; + } + + .highlight .region { + position: absolute; + pointer-events: none; + z-index: -1; + } + + .line { + white-space: pre; + + &.cursor-line .fold-marker::after { + opacity: 1; + } + } + + .fold-marker { + cursor: default; + + &::after { + .icon(0.8em, inline); + + content: @ellipsis; + padding-left: 0.2em; + } + } + + .placeholder-text { + position: absolute; + color: @text-color-subtle; + } + + .invisible-character { + font-weight: normal !important; + font-style: normal !important; + } + + .indent-guide { + display: inline-block; + box-shadow: inset 1px 0; + } + + .cursor { + z-index: 4; + pointer-events: none; + box-sizing: border-box; + position: absolute; + border-left: 1px solid; + opacity: 0; + } + + &.is-focused .cursor { + opacity: 1; + } + + .cursors.blink-off .cursor { + opacity: 0; + } +} + +atom-text-editor[mini] { + font-size: @input-font-size; + line-height: @component-line-height; + max-height: @component-line-height + 2; // +2 for borders + overflow: auto; +} + +atom-overlay { + position: fixed; + display: block; + z-index: 4; } From c338227dabd2f3488cec2a5b9b39b82b99049121 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 20 Apr 2017 23:04:14 -0600 Subject: [PATCH 289/403] Drop floats --- static/text-editor.less | 2 -- 1 file changed, 2 deletions(-) diff --git a/static/text-editor.less b/static/text-editor.less index ae0f564fae1..320a780f33e 100644 --- a/static/text-editor.less +++ b/static/text-editor.less @@ -7,7 +7,6 @@ atom-text-editor { font-family: Menlo, Consolas, 'DejaVu Sans Mono', monospace; .gutter-container { - float: left; width: min-content; background-color: inherit; } @@ -74,7 +73,6 @@ atom-text-editor { .lines { contain: strict; background-color: inherit; - float: left; will-change: transform; overflow: hidden; } From a890528ec9def049c8cf41e262e831ada451e99e Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 21 Apr 2017 09:31:18 +0200 Subject: [PATCH 290/403] Use `Math.round` for positions that are at the end of a line --- src/text-editor-component.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 829d319ae4f..0bcce166dce 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2069,7 +2069,7 @@ class TextEditorComponent { lineNodeClientLeft = lineNode.getBoundingClientRect().left } - positions.set(nextColumnToMeasure, lastTextNodeRight - lineNodeClientLeft) + positions.set(nextColumnToMeasure, Math.round(lastTextNodeRight - lineNodeClientLeft)) } } From 6a083e14a238a276e2d1c91d3c932fd3b99383b0 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 21 Apr 2017 10:29:33 +0200 Subject: [PATCH 291/403] Schedule component updates directly from the model The only event-based APIs we kept are for listening to changes in block decoration markers. --- spec/decoration-manager-spec.coffee | 13 +++-- src/decoration-manager.js | 7 ++- src/gutter-container.coffee | 2 + src/text-editor-component.js | 89 +++++++++++++---------------- src/text-editor.coffee | 14 ++++- 5 files changed, 66 insertions(+), 59 deletions(-) diff --git a/spec/decoration-manager-spec.coffee b/spec/decoration-manager-spec.coffee index ecef2bcc285..76bc37b7533 100644 --- a/spec/decoration-manager-spec.coffee +++ b/spec/decoration-manager-spec.coffee @@ -1,14 +1,15 @@ DecorationManager = require '../src/decoration-manager' +TextEditor = require '../src/text-editor' describe "DecorationManager", -> - [decorationManager, buffer, displayLayer, markerLayer1, markerLayer2] = [] + [decorationManager, buffer, editor, markerLayer1, markerLayer2] = [] beforeEach -> buffer = atom.project.bufferForPathSync('sample.js') - displayLayer = buffer.addDisplayLayer() - markerLayer1 = displayLayer.addMarkerLayer() - markerLayer2 = displayLayer.addMarkerLayer() - decorationManager = new DecorationManager(displayLayer) + editor = new TextEditor({buffer}) + markerLayer1 = editor.addMarkerLayer() + markerLayer2 = editor.addMarkerLayer() + decorationManager = new DecorationManager(editor) waitsForPromise -> atom.packages.activatePackage('language-javascript') @@ -50,7 +51,7 @@ describe "DecorationManager", -> expect(decorationManager.getOverlayDecorations()).toEqual [] it "does not allow destroyed marker layers to be decorated", -> - layer = displayLayer.addMarkerLayer() + layer = editor.addMarkerLayer() layer.destroy() expect(-> decorationManager.decorateMarkerLayer(layer, {type: 'highlight'}) diff --git a/src/decoration-manager.js b/src/decoration-manager.js index e98731623b7..cf3301fd4cb 100644 --- a/src/decoration-manager.js +++ b/src/decoration-manager.js @@ -4,8 +4,9 @@ const LayerDecoration = require('./layer-decoration') module.exports = class DecorationManager { - constructor (displayLayer) { - this.displayLayer = displayLayer + constructor (editor) { + this.editor = editor + this.displayLayer = this.editor.displayLayer this.emitter = new Emitter() this.decorationCountsByLayer = new Map() @@ -199,6 +200,7 @@ class DecorationManager { decorationsForMarker.add(decoration) if (decoration.isType('overlay')) this.overlayDecorations.add(decoration) this.observeDecoratedLayer(marker.layer, true) + this.editor.didAddDecoration(decoration) this.emitDidUpdateDecorations() this.emitter.emit('did-add-decoration', decoration) return decoration @@ -222,6 +224,7 @@ class DecorationManager { } emitDidUpdateDecorations () { + this.editor.scheduleComponentUpdate() this.emitter.emit('did-update-decorations') } diff --git a/src/gutter-container.coffee b/src/gutter-container.coffee index 74350835560..677fa4521f1 100644 --- a/src/gutter-container.coffee +++ b/src/gutter-container.coffee @@ -39,6 +39,7 @@ class GutterContainer break if not inserted @gutters.push newGutter + @scheduleComponentUpdate() @emitter.emit 'did-add-gutter', newGutter return newGutter @@ -70,6 +71,7 @@ class GutterContainer index = @gutters.indexOf(gutter) if index > -1 @gutters.splice(index, 1) + @scheduleComponentUpdate() @emitter.emit 'did-remove-gutter', gutter.name else throw new Error 'The given gutter cannot be removed because it is not ' + diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 0bcce166dce..6eccaaa37f8 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -54,6 +54,8 @@ class TextEditorComponent { this.props = props if (!props.model) props.model = new TextEditor() + this.props.model.component = this + if (props.element) { this.element = props.element } else { @@ -69,7 +71,6 @@ class TextEditorComponent { this.updatedSynchronously = this.props.updatedSynchronously this.didScrollDummyScrollbar = this.didScrollDummyScrollbar.bind(this) this.didMouseDownOnContent = this.didMouseDownOnContent.bind(this) - this.disposables = new CompositeDisposable() this.lineTopIndex = new LineTopIndex() this.updateScheduled = false this.measurements = null @@ -130,10 +131,8 @@ class TextEditorComponent { this.queryGuttersToRender() this.queryMaxLineNumberDigits() - + this.observeBlockDecorations() etch.updateSync(this) - - this.observeModel() } update (props) { @@ -2168,61 +2167,53 @@ class TextEditorComponent { return Point(row, column) } - observeModel () { - const {model, platform} = this.props - model.component = this - const scheduleUpdate = this.scheduleUpdate.bind(this) - this.disposables.add(model.displayLayer.onDidReset(() => { - this.spliceLineTopIndex(0, Infinity, Infinity) - this.scheduleUpdate() - })) - this.disposables.add(model.displayLayer.onDidChangeSync((changes) => { - for (let i = 0; i < changes.length; i++) { - const change = changes[i] - this.spliceLineTopIndex( - change.start.row, - change.oldExtent.row, - change.newExtent.row - ) - } + didResetDisplayLayer () { + this.spliceLineTopIndex(0, Infinity, Infinity) + this.scheduleUpdate() + } - this.scheduleUpdate() - })) - this.disposables.add(model.onDidUpdateDecorations(scheduleUpdate)) - this.disposables.add(model.onDidAddGutter(scheduleUpdate)) - this.disposables.add(model.onDidRemoveGutter(scheduleUpdate)) - this.disposables.add(model.selectionsMarkerLayer.onDidUpdate(this.didUpdateSelections.bind(this))) - this.disposables.add(model.onDidRequestAutoscroll(this.didRequestAutoscroll.bind(this))) - this.disposables.add(model.observeDecorations((decoration) => { - if (decoration.getProperties().type === 'block') this.observeBlockDecoration(decoration) - })) + didChangeDisplayLayer (changes) { + for (let i = 0; i < changes.length; i++) { + const {start, oldExtent, newExtent} = changes[i] + this.spliceLineTopIndex(start.row, oldExtent.row, newExtent.row) + } + + this.scheduleUpdate() + } + + didChangeSelectionRange () { + const {model, platform} = this.props if (platform === 'linux') { - let immediateId = null + if (this.selectionClipboardImmediateId) { + clearImmediate(this.selectionClipboardImmediateId) + } - this.disposables.add(model.onDidChangeSelectionRange(() => { - if (immediateId) { - clearImmediate(immediateId) - } + this.selectionClipboardImmediateId = setImmediate(() => { + this.selectionClipboardImmediateId = null - immediateId = setImmediate(() => { - immediateId = null + if (model.isDestroyed()) return - if (model.isDestroyed()) return + const selectedText = model.getSelectedText() + if (selectedText) { + // This uses ipcRenderer.send instead of clipboard.writeText because + // clipboard.writeText is a sync ipcRenderer call on Linux and that + // will slow down selections. + electron.ipcRenderer.send('write-text-to-selection-clipboard', selectedText) + } + }) + } + } - const selectedText = model.getSelectedText() - if (selectedText) { - // This uses ipcRenderer.send instead of clipboard.writeText because - // clipboard.writeText is a sync ipcRenderer call on Linux and that - // will slow down selections. - electron.ipcRenderer.send('write-text-to-selection-clipboard', selectedText) - } - }) - })) + observeBlockDecorations () { + const {model} = this.props + const decorations = model.getDecorations({type: 'block'}) + for (let i = 0; i < decorations.length; i++) { + this.didAddBlockDecoration(decorations[i]) } } - observeBlockDecoration (decoration) { + didAddBlockDecoration (decoration) { const marker = decoration.getMarker() const {position} = decoration.getProperties() const row = marker.getHeadScreenPosition().row diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 5330a564ce9..9db3df32fde 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -211,7 +211,7 @@ class TextEditor extends Model @selectionsMarkerLayer ?= @addMarkerLayer(maintainHistory: true, persistent: true) @selectionsMarkerLayer.trackDestructionInOnDidCreateMarkerCallbacks = true - @decorationManager = new DecorationManager(@displayLayer) + @decorationManager = new DecorationManager(this) @decorateMarkerLayer(@selectionsMarkerLayer, type: 'cursor') @decorateCursorLine() unless @isMini() @@ -445,14 +445,17 @@ class TextEditor extends Model @emitter.on 'did-terminate-pending-state', callback subscribeToDisplayLayer: -> - @disposables.add @selectionsMarkerLayer.onDidCreateMarker @addSelection.bind(this) @disposables.add @tokenizedBuffer.onDidChangeGrammar @handleGrammarChange.bind(this) @disposables.add @displayLayer.onDidChangeSync (e) => @mergeIntersectingSelections() + @component?.didChangeDisplayLayer(e) @emitter.emit 'did-change', e @disposables.add @displayLayer.onDidReset => @mergeIntersectingSelections() + @component?.didResetDisplayLayer() @emitter.emit 'did-change', {} + @disposables.add @selectionsMarkerLayer.onDidCreateMarker @addSelection.bind(this) + @disposables.add @selectionsMarkerLayer.onDidUpdate => @component?.didUpdateSelections() destroyed: -> @disposables.dispose() @@ -720,6 +723,11 @@ class TextEditor extends Model onDidRemoveDecoration: (callback) -> @decorationManager.onDidRemoveDecoration(callback) + # Called by DecorationManager when a decoration is added. + didAddDecoration: (decoration) -> + if decoration.isType('block') + @component?.didAddBlockDecoration(decoration) + # Extended: Calls your `callback` when the placeholder text is changed. # # * `callback` {Function} @@ -2843,6 +2851,7 @@ class TextEditor extends Model # Called by the selection selectionRangeChanged: (event) -> + @component?.didChangeSelectionRange() @emitter.emit 'did-change-selection-range', event createLastSelectionIfNeeded: -> @@ -3466,6 +3475,7 @@ class TextEditor extends Model scrollToScreenRange: (screenRange, options = {}) -> screenRange = @clipScreenRange(screenRange) scrollEvent = {screenRange, options} + @component?.didRequestAutoscroll(scrollEvent) @emitter.emit "did-request-autoscroll", scrollEvent getHorizontalScrollbarHeight: -> From 8f5e4216dc2a25fc43ce569d2b75a6105f009b43 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 21 Apr 2017 10:38:26 +0200 Subject: [PATCH 292/403] Fix more lint errors --- src/text-editor-component.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 6eccaaa37f8..b175a407d70 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1,7 +1,6 @@ /* global ResizeObserver */ const etch = require('etch') -const {CompositeDisposable} = require('event-kit') const {Point, Range} = require('text-buffer') const LineTopIndex = require('line-top-index') const TextEditor = require('./text-editor') @@ -1175,7 +1174,7 @@ class TextEditorComponent { this.decorationsToMeasure.cursors.forEach((cursor) => { const {screenPosition, className, style} = cursor - const {row, column} = cursor.screenPosition + const {row, column} = screenPosition const pixelTop = this.pixelPositionAfterBlocksForRow(row) const pixelLeft = this.pixelLeftForRowAndColumn(row, column) @@ -2713,7 +2712,8 @@ class LineNumberGutterComponent { number = NBSP_CHARACTER.repeat(maxDigits - number.length) + number const lineNumberProps = { - key, className, + key, + className, style: {width: width + 'px'}, dataset: {bufferRow} } From 906b3b05d6f175137f52f904f788f62c2e231b6f Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 21 Apr 2017 10:59:21 +0200 Subject: [PATCH 293/403] Update mock text editor in gutter-container-spec.coffee --- spec/gutter-container-spec.coffee | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/gutter-container-spec.coffee b/spec/gutter-container-spec.coffee index e38367835be..dc4af0b8c17 100644 --- a/spec/gutter-container-spec.coffee +++ b/spec/gutter-container-spec.coffee @@ -3,7 +3,9 @@ GutterContainer = require '../src/gutter-container' describe 'GutterContainer', -> gutterContainer = null - fakeTextEditor = {} + fakeTextEditor = { + scheduleComponentUpdate: -> + } beforeEach -> gutterContainer = new GutterContainer fakeTextEditor From 72d6316459302e48e2a8dfa3386de9b7b6713626 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 21 Apr 2017 12:29:33 +0200 Subject: [PATCH 294/403] Fix shift-scroll on Windows and Linux --- spec/text-editor-component-spec.js | 123 ++++++++++++++++++++++++++++- src/text-editor-component.js | 33 +++++--- 2 files changed, 144 insertions(+), 12 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 0f7164b9b3d..0588aafa0bb 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -842,6 +842,126 @@ describe('TextEditorComponent', () => { }) }) + describe('scrolling via the mouse wheel', () => { + it('scrolls vertically when deltaY is not 0', () => { + const mouseWheelScrollSensitivity = 0.4 + const {component, editor} = buildComponent({height: 50, mouseWheelScrollSensitivity}) + + { + const expectedScrollTop = 20 * mouseWheelScrollSensitivity + component.didMouseWheel({deltaX: 0, deltaY: 20}) + expect(component.getScrollTop()).toBe(expectedScrollTop) + expect(component.refs.content.style.transform).toBe(`translate(0px, -${expectedScrollTop}px)`) + } + + { + const expectedScrollTop = component.getScrollTop() - (10 * mouseWheelScrollSensitivity) + component.didMouseWheel({deltaX: 0, deltaY: -10}) + expect(component.getScrollTop()).toBe(expectedScrollTop) + expect(component.refs.content.style.transform).toBe(`translate(0px, -${expectedScrollTop}px)`) + } + }) + + it('scrolls horizontally when deltaX is not 0', () => { + const mouseWheelScrollSensitivity = 0.4 + const {component, editor} = buildComponent({width: 50, mouseWheelScrollSensitivity}) + + { + const expectedScrollLeft = 20 * mouseWheelScrollSensitivity + component.didMouseWheel({deltaX: 20, deltaY: 0}) + expect(component.getScrollLeft()).toBe(expectedScrollLeft) + expect(component.refs.content.style.transform).toBe(`translate(-${expectedScrollLeft}px, 0px)`) + } + + { + const expectedScrollLeft = component.getScrollLeft() - (10 * mouseWheelScrollSensitivity) + component.didMouseWheel({deltaX: -10, deltaY: 0}) + expect(component.getScrollLeft()).toBe(expectedScrollLeft) + expect(component.refs.content.style.transform).toBe(`translate(-${expectedScrollLeft}px, 0px)`) + } + }) + + it('inverts deltaX and deltaY when holding shift on Windows and Linux', async () => { + const mouseWheelScrollSensitivity = 0.4 + const {component, editor} = buildComponent({height: 50, width: 50, mouseWheelScrollSensitivity}) + + component.props.platform = 'linux' + { + const expectedScrollTop = 20 * mouseWheelScrollSensitivity + component.didMouseWheel({deltaX: 0, deltaY: 20}) + expect(component.getScrollTop()).toBe(expectedScrollTop) + expect(component.refs.content.style.transform).toBe(`translate(0px, -${expectedScrollTop}px)`) + await setScrollTop(component, 0) + } + + { + const expectedScrollLeft = 20 * mouseWheelScrollSensitivity + component.didMouseWheel({deltaX: 0, deltaY: 20, shiftKey: true}) + expect(component.getScrollLeft()).toBe(expectedScrollLeft) + expect(component.refs.content.style.transform).toBe(`translate(-${expectedScrollLeft}px, 0px)`) + await setScrollLeft(component, 0) + } + + { + const expectedScrollTop = 20 * mouseWheelScrollSensitivity + component.didMouseWheel({deltaX: 20, deltaY: 0, shiftKey: true}) + expect(component.getScrollTop()).toBe(expectedScrollTop) + expect(component.refs.content.style.transform).toBe(`translate(0px, -${expectedScrollTop}px)`) + await setScrollTop(component, 0) + } + + component.props.platform = 'win32' + { + const expectedScrollTop = 20 * mouseWheelScrollSensitivity + component.didMouseWheel({deltaX: 0, deltaY: 20}) + expect(component.getScrollTop()).toBe(expectedScrollTop) + expect(component.refs.content.style.transform).toBe(`translate(0px, -${expectedScrollTop}px)`) + await setScrollTop(component, 0) + } + + { + const expectedScrollLeft = 20 * mouseWheelScrollSensitivity + component.didMouseWheel({deltaX: 0, deltaY: 20, shiftKey: true}) + expect(component.getScrollLeft()).toBe(expectedScrollLeft) + expect(component.refs.content.style.transform).toBe(`translate(-${expectedScrollLeft}px, 0px)`) + await setScrollLeft(component, 0) + } + + { + const expectedScrollTop = 20 * mouseWheelScrollSensitivity + component.didMouseWheel({deltaX: 20, deltaY: 0, shiftKey: true}) + expect(component.getScrollTop()).toBe(expectedScrollTop) + expect(component.refs.content.style.transform).toBe(`translate(0px, -${expectedScrollTop}px)`) + await setScrollTop(component, 0) + } + + component.props.platform = 'darwin' + { + const expectedScrollTop = 20 * mouseWheelScrollSensitivity + component.didMouseWheel({deltaX: 0, deltaY: 20}) + expect(component.getScrollTop()).toBe(expectedScrollTop) + expect(component.refs.content.style.transform).toBe(`translate(0px, -${expectedScrollTop}px)`) + await setScrollTop(component, 0) + } + + { + const expectedScrollTop = 20 * mouseWheelScrollSensitivity + component.didMouseWheel({deltaX: 0, deltaY: 20, shiftKey: true}) + expect(component.getScrollTop()).toBe(expectedScrollTop) + expect(component.refs.content.style.transform).toBe(`translate(0px, -${expectedScrollTop}px)`) + await setScrollTop(component, 0) + } + + { + const expectedScrollLeft = 20 * mouseWheelScrollSensitivity + component.didMouseWheel({deltaX: 20, deltaY: 0, shiftKey: true}) + expect(component.getScrollLeft()).toBe(expectedScrollLeft) + expect(component.refs.content.style.transform).toBe(`translate(-${expectedScrollLeft}px, 0px)`) + await setScrollLeft(component, 0) + } + }) + }) + describe('line and line number decorations', () => { it('adds decoration classes on screen lines spanned by decorated markers', async () => { const {component, element, editor} = buildComponent({width: 435, attach: false}) @@ -2974,7 +3094,8 @@ function buildComponent (params = {}) { model: editor, rowsPerTile: params.rowsPerTile, updatedSynchronously: false, - platform: params.platform + platform: params.platform, + mouseWheelScrollSensitivity: params.mouseWheelScrollSensitivity }) const {element} = component if (!editor.getAutoHeight()) { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index b175a407d70..d13259b963b 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -19,7 +19,6 @@ const KOREAN_CHARACTER = '세' const NBSP_CHARACTER = '\u00a0' const ZERO_WIDTH_NBSP_CHARACTER = '\ufeff' const MOUSE_DRAG_AUTOSCROLL_MARGIN = 40 -const MOUSE_WHEEL_SCROLL_SENSITIVITY = 0.8 const CURSOR_BLINK_RESUME_DELAY = 300 const CURSOR_BLINK_PERIOD = 800 @@ -1361,9 +1360,17 @@ class TextEditorComponent { } didMouseWheel (event) { + const scrollSensitivity = this.props.mouseWheelScrollSensitivity || 0.8 + let {deltaX, deltaY} = event - deltaX = deltaX * MOUSE_WHEEL_SCROLL_SENSITIVITY - deltaY = deltaY * MOUSE_WHEEL_SCROLL_SENSITIVITY + deltaX = deltaX * scrollSensitivity + deltaY = deltaY * scrollSensitivity + + if (this.getPlatform() !== 'darwin' && event.shiftKey) { + let temp = deltaX + deltaX = deltaY + deltaY = temp + } const scrollPositionChanged = this.setScrollLeft(this.getScrollLeft() + deltaX) || @@ -1514,12 +1521,12 @@ class TextEditorComponent { } didMouseDownOnContent (event) { - const {model, platform} = this.props + const {model} = this.props const {target, button, detail, ctrlKey, shiftKey, metaKey} = event // Only handle mousedown events for left mouse button (or the middle mouse // button on Linux where it pastes the selection clipboard). - if (!(button === 0 || (platform === 'linux' && button === 1))) return + if (!(button === 0 || (this.getPlatform() === 'linux' && button === 1))) return const screenPosition = this.screenPositionForMouseEvent(event) @@ -1530,14 +1537,14 @@ class TextEditorComponent { } // Handle middle mouse button only on Linux (paste clipboard) - if (platform === 'linux' && button === 1) { + if (this.getPlatform() === 'linux' && button === 1) { const selection = clipboard.readText('selection') model.setCursorScreenPosition(screenPosition, {autoscroll: false}) model.insertText(selection) return } - const addOrRemoveSelection = metaKey || (ctrlKey && platform !== 'darwin') + const addOrRemoveSelection = metaKey || (ctrlKey && this.getPlatform() !== 'darwin') switch (detail) { case 1: @@ -1582,7 +1589,7 @@ class TextEditorComponent { } didMouseDownOnLineNumberGutter (event) { - const {model, platform} = this.props + const {model} = this.props const {target, button, ctrlKey, shiftKey, metaKey} = event // Only handle mousedown events for left mouse button @@ -1596,7 +1603,7 @@ class TextEditorComponent { return } - const addOrRemoveSelection = metaKey || (ctrlKey && platform !== 'darwin') + const addOrRemoveSelection = metaKey || (ctrlKey && this.getPlatform() !== 'darwin') const endBufferRow = model.bufferPositionForScreenPosition([clickedScreenRow, Infinity]).row const clickedLineBufferRange = Range(Point(startBufferRow, 0), Point(endBufferRow + 1, 0)) @@ -2181,9 +2188,9 @@ class TextEditorComponent { } didChangeSelectionRange () { - const {model, platform} = this.props + const {model} = this.props - if (platform === 'linux') { + if (this.getPlatform() === 'linux') { if (this.selectionClipboardImmediateId) { clearImmediate(this.selectionClipboardImmediateId) } @@ -2568,6 +2575,10 @@ class TextEditorComponent { isInputEnabled (inputEnabled) { return this.props.inputEnabled != null ? this.props.inputEnabled : true } + + getPlatform () { + return this.props.platform || process.platform + } } class DummyScrollbarComponent { From 37b5d2eb4dbbf5a23baf70c0ee1994c77e6dc05b Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 21 Apr 2017 16:54:59 +0200 Subject: [PATCH 295/403] Restore scrollbar positions correctly on reload --- spec/text-editor-component-spec.js | 7 ++++++- src/text-editor-component.js | 32 ++++++++++++++---------------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 0588aafa0bb..e5ec8d98aa1 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -212,10 +212,13 @@ describe('TextEditorComponent', () => { }) - it('updates the bottom/right of dummy scrollbars and client height/width measurements when scrollbar styles change', async () => { + it('updates the bottom/right of dummy scrollbars and client height/width measurements without forgetting the previous scroll top/left when scrollbar styles change', async () => { const {component, element, editor} = buildComponent({height: 100, width: 100}) expect(getHorizontalScrollbarHeight(component)).toBeGreaterThan(10) expect(getVerticalScrollbarWidth(component)).toBeGreaterThan(10) + setScrollTop(component, 20) + setScrollLeft(component, 10) + await component.getNextUpdatePromise() const style = document.createElement('style') style.textContent = '::-webkit-scrollbar { height: 10px; width: 10px; }' @@ -228,6 +231,8 @@ describe('TextEditorComponent', () => { expect(getVerticalScrollbarWidth(component)).toBe(10) expect(component.refs.horizontalScrollbar.element.style.right).toBe('10px') expect(component.refs.verticalScrollbar.element.style.bottom).toBe('10px') + expect(component.refs.horizontalScrollbar.element.scrollLeft).toBe(10) + expect(component.refs.verticalScrollbar.element.scrollTop).toBe(20) expect(component.getScrollContainerClientHeight()).toBe(100 - 10) expect(component.getScrollContainerClientWidth()).toBe(100 - component.getGutterContainerWidth() - 10) }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index d13259b963b..bde57efaf59 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -339,6 +339,14 @@ class TextEditorComponent { this.scrollTopPending = false this.scrollLeftPending = false if (this.remeasureScrollbars) { + // Flush stored scroll positions to the vertical and the horizontal + // scrollbars. This is because they have just been destroyed and recreated + // as a result of their remeasurement, but we could not assign the scroll + // top while they were initialized because they were not attached to the + // DOM yet. + this.refs.verticalScrollbar.flushScrollPosition() + this.refs.horizontalScrollbar.flushScrollPosition() + this.measureScrollbarDimensions() this.remeasureScrollbars = false etch.updateSync(this) @@ -2585,31 +2593,21 @@ class DummyScrollbarComponent { constructor (props) { this.props = props etch.initialize(this) - if (this.props.orientation === 'horizontal') { - this.element.scrollLeft = this.props.scrollLeft - } else { - this.element.scrollTop = this.props.scrollTop - } } update (newProps) { const oldProps = this.props this.props = newProps etch.updateSync(this) - if (this.props.orientation === 'horizontal') { - if (newProps.scrollLeft !== oldProps.scrollLeft) { - this.element.scrollLeft = this.props.scrollLeft - } - } else { - if (newProps.scrollTop !== oldProps.scrollTop) { - this.element.scrollTop = this.props.scrollTop - } - } + + const shouldFlushScrollPosition = ( + newProps.scrollTop !== oldProps.scrollTop || + newProps.scrollLeft !== oldProps.scrollLeft + ) + if (shouldFlushScrollPosition) this.flushScrollPosition() } - // Scroll position must be updated after the inner element is updated to - // ensure the element has an adequate scrollHeight/scrollWidth - updateScrollPosition () { + flushScrollPosition () { if (this.props.orientation === 'horizontal') { this.element.scrollLeft = this.props.scrollLeft } else { From f45ff053061bd58b204c823fb547e2a727d8fc85 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 21 Apr 2017 17:42:35 +0200 Subject: [PATCH 296/403] Add {get,set}FirstVisibleScreen{Row,Column} to TextEditorElement --- src/text-editor-element.js | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/text-editor-element.js b/src/text-editor-element.js index 428c44ee5d4..e9c0b687f3a 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -233,9 +233,31 @@ class TextEditorElement extends HTMLElement { // * {blockDecoration} A {Decoration} representing the block decoration you // want to update the dimensions of. invalidateBlockDecorationDimensions () { - if (this.component) { - this.component.invalidateBlockDecorationDimensions(...arguments) - } + this.getComponent().invalidateBlockDecorationDimensions(...arguments) + } + + setFirstVisibleScreenRow (row) { + this.getModel().setFirstVisibleScreenRow(row) + } + + getFirstVisibleScreenRow () { + return this.getModel().getFirstVisibleScreenRow() + } + + getLastVisibleScreenRow () { + return this.getModel().getLastVisibleScreenRow() + } + + getVisibleRowRange () { + return this.getModel().getVisibleRowRange() + } + + setFirstVisibleScreenColumn (column) { + return this.getModel().setFirstVisibleScreenColumn(column) + } + + getFirstVisibleScreenColumn () { + return this.getModel().getFirstVisibleScreenColumn() } } From 59ae239a8cbdce24f97a4d729d59c0c553b8fe4c Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 21 Apr 2017 18:06:01 +0200 Subject: [PATCH 297/403] Provide an `editorElement` shim on TextEditor Signed-off-by: Nathan Sobo --- src/text-editor.coffee | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 9db3df32fde..4864663fe06 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -104,6 +104,17 @@ class TextEditor extends Model Object.defineProperty @prototype, "element", get: -> @getElement() + Object.defineProperty @prototype, "editorElement", + get: -> + Grim.deprecate(""" + `TextEditor.prototype.editorElement` has always been private, but now + it is gone. Reading the `editorElement` property still returns a + reference to the editor element but this field will be removed in a + later version of Atom, so we recommend using the `element` property instead. + """) + + @getElement() + Object.defineProperty(@prototype, 'displayBuffer', get: -> Grim.deprecate(""" `TextEditor.prototype.displayBuffer` has always been private, but now From e1ae3749c01240ef0668da63d78c796b6af22b89 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 21 Apr 2017 18:11:36 +0200 Subject: [PATCH 298/403] Add a `pixelPositionForMouseEvent` method This was a private method in the previous implementation that was used by some packages. Signed-off-by: Nathan Sobo --- src/text-editor-component.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index bde57efaf59..8d1d4bb9936 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1727,15 +1727,19 @@ class TextEditorComponent { if (scrolled) this.updateSync() } - screenPositionForMouseEvent ({clientX, clientY}) { + screenPositionForMouseEvent (event) { + return this.screenPositionForPixelPosition(this.pixelPositionForMouseEvent(event)) + } + + pixelPositionForMouseEvent ({clientX, clientY}) { const scrollContainerRect = this.refs.scrollContainer.getBoundingClientRect() clientX = Math.min(scrollContainerRect.right, Math.max(scrollContainerRect.left, clientX)) clientY = Math.min(scrollContainerRect.bottom, Math.max(scrollContainerRect.top, clientY)) const linesRect = this.refs.lineTiles.getBoundingClientRect() - return this.screenPositionForPixelPosition({ + return { top: clientY - linesRect.top, left: clientX - linesRect.left - }) + } } didUpdateSelections () { From efdb044ce6cf808326608c9a1f30792323f6f21e Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 21 Apr 2017 18:30:14 +0200 Subject: [PATCH 299/403] Use `cursor:text` on atom-text-editor elements Signed-off-by: Nathan Sobo --- static/cursors.less | 4 ++-- static/text-editor.less | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/static/cursors.less b/static/cursors.less index 0b54c6ea1c5..5cbfadef640 100644 --- a/static/cursors.less +++ b/static/cursors.less @@ -13,14 +13,14 @@ // Editors & when ( lightness(@syntax-background-color) < 50% ) { - .platform-darwin atom-text-editor:not([mini]) .editor-contents--private { + .platform-darwin atom-text-editor:not([mini]) { .cursor-white(); } } // Mini Editors & when ( lightness(@input-background-color) < 50% ) { - .platform-darwin atom-text-editor[mini] .editor-contents--private { + .platform-darwin atom-text-editor[mini] { .cursor-white(); } } diff --git a/static/text-editor.less b/static/text-editor.less index 320a780f33e..9acdb90da8d 100644 --- a/static/text-editor.less +++ b/static/text-editor.less @@ -5,17 +5,18 @@ atom-text-editor { display: flex; font-family: Menlo, Consolas, 'DejaVu Sans Mono', monospace; + cursor: text; .gutter-container { width: min-content; background-color: inherit; + cursor: default; } .gutter { overflow: hidden; z-index: 0; text-align: right; - cursor: default; min-width: 1em; box-sizing: border-box; background-color: inherit; From 4bcace162818a441451f8c733e6155dbc374a5d5 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 21 Apr 2017 18:42:02 +0200 Subject: [PATCH 300/403] Don't remeasure scrollbars for mini editors Signed-off-by: Nathan Sobo --- spec/text-editor-component-spec.js | 6 ++++++ src/text-editor-component.js | 6 ++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index e5ec8d98aa1..f78197a2b33 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -235,6 +235,12 @@ describe('TextEditorComponent', () => { expect(component.refs.verticalScrollbar.element.scrollTop).toBe(20) expect(component.getScrollContainerClientHeight()).toBe(100 - 10) expect(component.getScrollContainerClientWidth()).toBe(100 - component.getGutterContainerWidth() - 10) + + // Ensure we don't throw an error trying to remeasure non-existent scrollbars for mini editors. + await editor.update({mini: true}) + TextEditor.didUpdateScrollbarStyles() + component.scheduleUpdate() + await component.getNextUpdatePromise() }) it('renders cursors within the visible row range', async () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 8d1d4bb9936..24be5326717 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1428,8 +1428,10 @@ class TextEditorComponent { } didUpdateScrollbarStyles () { - this.remeasureScrollbars = true - this.scheduleUpdate() + if (!this.props.model.isMini()) { + this.remeasureScrollbars = true + this.scheduleUpdate() + } } didTextInput (event) { From 6ed7cd97cc743ada3afb40811a37a83b83b1d3f6 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 21 Apr 2017 18:58:39 +0200 Subject: [PATCH 301/403] Add highlight decoration classes to region elements as well Signed-off-by: Nathan Sobo --- spec/text-editor-component-spec.js | 8 ++++---- src/text-editor-component.js | 9 +++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index f78197a2b33..b0601f3115e 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1120,7 +1120,7 @@ describe('TextEditorComponent', () => { await component.getNextUpdatePromise() { - const regions = element.querySelectorAll('.highlight.a .region') + const regions = element.querySelectorAll('.highlight.a .region.a') expect(regions.length).toBe(1) const regionRect = regions[0].getBoundingClientRect() expect(regionRect.top).toBe(lineNodeForScreenRow(component, 1).getBoundingClientRect().top) @@ -1132,7 +1132,7 @@ describe('TextEditorComponent', () => { await component.getNextUpdatePromise() { - const regions = element.querySelectorAll('.highlight.a .region') + const regions = element.querySelectorAll('.highlight.a .region.a') expect(regions.length).toBe(1) const regionRect = regions[0].getBoundingClientRect() expect(regionRect.top).toBe(lineNodeForScreenRow(component, 1).getBoundingClientRect().top) @@ -1154,7 +1154,7 @@ describe('TextEditorComponent', () => { // across 2 different tiles expect(element.querySelectorAll('.highlight.a').length).toBe(2) - const regions = element.querySelectorAll('.highlight.a .region') + const regions = element.querySelectorAll('.highlight.a .region.a') expect(regions.length).toBe(2) const region0Rect = regions[0].getBoundingClientRect() expect(region0Rect.top).toBe(lineNodeForScreenRow(component, 2).getBoundingClientRect().top) @@ -1176,7 +1176,7 @@ describe('TextEditorComponent', () => { // Still split across 2 tiles expect(element.querySelectorAll('.highlight.a').length).toBe(2) - const regions = element.querySelectorAll('.highlight.a .region') + const regions = element.querySelectorAll('.highlight.a .region.a') expect(regions.length).toBe(4) // Each tile renders its const region0Rect = regions[0].getBoundingClientRect() diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 24be5326717..bb1ee2e9dad 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -3421,10 +3421,11 @@ class HighlightComponent { startPixelTop -= parentTileTop endPixelTop -= parentTileTop + let regionClassName = 'region ' + className let children if (screenRange.start.row === screenRange.end.row) { children = $.div({ - className: 'region', + className: regionClassName, style: { position: 'absolute', boxSizing: 'border-box', @@ -3437,7 +3438,7 @@ class HighlightComponent { } else { children = [] children.push($.div({ - className: 'region', + className: regionClassName, style: { position: 'absolute', boxSizing: 'border-box', @@ -3450,7 +3451,7 @@ class HighlightComponent { if (screenRange.end.row - screenRange.start.row > 1) { children.push($.div({ - className: 'region', + className: regionClassName, style: { position: 'absolute', boxSizing: 'border-box', @@ -3464,7 +3465,7 @@ class HighlightComponent { if (endPixelLeft > 0) { children.push($.div({ - className: 'region', + className: regionClassName, style: { position: 'absolute', boxSizing: 'border-box', From 207cd310549b26eddcea46b48017f8f4e38a495c Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 21 Apr 2017 12:30:41 -0600 Subject: [PATCH 302/403] Add highlights class for package compatibility --- src/text-editor-component.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index bb1ee2e9dad..afbacca827d 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2979,6 +2979,7 @@ class LinesTileComponent { return $.div( { + className: 'highlights', style: { position: 'absolute', contain: 'strict', From 0996d90be3da6c2da6d908f2befbe232b644a63d Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 21 Apr 2017 12:41:45 -0600 Subject: [PATCH 303/403] Add scrollbar classes in case any packages or themes target them --- src/text-editor-component.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index afbacca827d..36d8ec45a63 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -822,6 +822,7 @@ class TextEditorComponent { dummyScrollbarVnodes.push($.div( { ref: 'scrollbarCorner', + className: 'scrollbar-corner', style: { position: 'absolute', height: '20px', @@ -2652,6 +2653,7 @@ class DummyScrollbarComponent { return $.div( { + className: `${this.props.orientation}-scrollbar`, style: outerStyle, on: { scroll: this.props.didScroll, From 44539b1dc60783e9d0bf7a20c4144ce45e1c7b8d Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 21 Apr 2017 12:54:23 -0600 Subject: [PATCH 304/403] Remove some redundant styling --- src/text-editor-component.js | 4 +--- static/text-editor.less | 5 ----- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 36d8ec45a63..6d3a6f13101 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -439,7 +439,6 @@ class TextEditorComponent { if (!this.measuredContent || !this.gutterContainerVnode) { const innerStyle = { willChange: 'transform', - backgroundColor: 'inherit', display: 'flex' } @@ -638,8 +637,7 @@ class TextEditorComponent { contain: 'strict', overflow: 'hidden', width: this.getScrollWidth() + 'px', - height: this.getScrollHeight() + 'px', - backgroundColor: 'inherit' + height: this.getScrollHeight() + 'px' } }, tileNodes) } diff --git a/static/text-editor.less b/static/text-editor.less index 9acdb90da8d..69c8dce4888 100644 --- a/static/text-editor.less +++ b/static/text-editor.less @@ -48,7 +48,6 @@ atom-text-editor { .line-numbers { width: max-content; background-color: inherit; - contain: content; } .line-number { @@ -72,10 +71,7 @@ atom-text-editor { } .lines { - contain: strict; background-color: inherit; - will-change: transform; - overflow: hidden; } .highlight { @@ -84,7 +80,6 @@ atom-text-editor { } .highlight .region { - position: absolute; pointer-events: none; z-index: -1; } From b54dbb58abcb41ec11c00c3fae2bc1db9db646fa Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 21 Apr 2017 13:16:53 -0600 Subject: [PATCH 305/403] Add missing methods on TextEditorElement --- src/text-editor-element.js | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/text-editor-element.js b/src/text-editor-element.js index e9c0b687f3a..0bcb60a0906 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -142,10 +142,27 @@ class TextEditorElement extends HTMLElement { getBaseCharacterWidth () { return this.getComponent().getBaseCharacterWidth() } + getMaxScrollTop () { return this.getComponent().getMaxScrollTop() } + getScrollHeight () { + return this.getComponent().getScrollHeight() + } + + getScrollWidth () { + return this.getComponent().getScrollWidth() + } + + getVerticalScrollbarWidth () { + return this.getComponent().getVerticalScrollbarWidth() + } + + getHorizontalScrollbarHeight () { + return this.getComponent().getHorizontalScrollbarHeight() + } + getScrollTop () { return this.getComponent().getScrollTop() } @@ -156,6 +173,14 @@ class TextEditorElement extends HTMLElement { component.scheduleUpdate() } + getScrollBottom () { + return this.getComponent().getScrollBottom() + } + + setScrollBottom (scrollBottom) { + return this.getComponent().setScrollBottom(scrollBottom) + } + getScrollLeft () { return this.getComponent().getScrollLeft() } @@ -166,6 +191,24 @@ class TextEditorElement extends HTMLElement { component.scheduleUpdate() } + getScrollRight () { + return this.getComponent().getScrollRight() + } + + setScrollRight (scrollRight) { + return this.getComponent().setScrollRight(scrollRight) + } + + // Essential: Scrolls the editor to the top. + scrollToTop () { + this.setScrollTop(0) + } + + // Essential: Scrolls the editor to the bottom. + scrollToBottom () { + this.setScrollTop(Infinity) + } + hasFocus () { return this.getComponent().focused } From a5a80448cb20f198656d62eafae8afba0a77e96b Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 21 Apr 2017 13:17:13 -0600 Subject: [PATCH 306/403] Add intersectsVisibleRowRange on TextEditorElement --- spec/text-editor-element-spec.js | 20 ++++++++++++++++++++ src/text-editor-element.js | 12 ++++++++++++ 2 files changed, 32 insertions(+) diff --git a/spec/text-editor-element-spec.js b/spec/text-editor-element-spec.js index 4a6655714a4..afa882320c7 100644 --- a/spec/text-editor-element-spec.js +++ b/spec/text-editor-element-spec.js @@ -309,6 +309,26 @@ describe('TextEditorElement', () => { }) ) + describe('::intersectsVisibleRowRange(start, end)', () => { + it('returns true if the given row range intersects the visible row range', async () => { + const element = buildTextEditorElement() + const editor = element.getModel() + editor.update({autoHeight: false}) + element.getModel().setText('x\n'.repeat(20)) + element.style.height = '120px' + await element.getNextUpdatePromise() + element.setScrollTop(80) + await element.getNextUpdatePromise() + expect(element.getVisibleRowRange()).toEqual([4, 11]) + + expect(element.intersectsVisibleRowRange(0, 4)).toBe(false) + expect(element.intersectsVisibleRowRange(0, 5)).toBe(true) + expect(element.intersectsVisibleRowRange(5, 8)).toBe(true) + expect(element.intersectsVisibleRowRange(11, 12)).toBe(false) + expect(element.intersectsVisibleRowRange(12, 13)).toBe(false) + }) + }) + describe('events', () => { let element = null diff --git a/src/text-editor-element.js b/src/text-editor-element.js index 0bcb60a0906..947bf3a8e82 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -295,6 +295,18 @@ class TextEditorElement extends HTMLElement { return this.getModel().getVisibleRowRange() } + intersectsVisibleRowRange (startRow, endRow) { + return !( + endRow <= this.getFirstVisibleScreenRow() || + this.getLastVisibleScreenRow() <= startRow + ) + } + + selectionIntersectsVisibleRowRange (selection) { + const {start, end} = selection.getScreenRange() + return this.intersectsVisibleRowRange(start.row, end.row + 1) + } + setFirstVisibleScreenColumn (column) { return this.getModel().setFirstVisibleScreenColumn(column) } From 305fd14cd9776d46b41d2eb813a294735f0e9361 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 21 Apr 2017 13:44:12 -0600 Subject: [PATCH 307/403] Add TextEditorElement.pixelRectRangeForScreenRange for compatibility --- spec/text-editor-element-spec.js | 16 ++++++++++++++++ src/text-editor-element.js | 27 ++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/spec/text-editor-element-spec.js b/spec/text-editor-element-spec.js index afa882320c7..56601c455d3 100644 --- a/spec/text-editor-element-spec.js +++ b/spec/text-editor-element-spec.js @@ -329,6 +329,22 @@ describe('TextEditorElement', () => { }) }) + describe('::pixelRectForScreenRange(range)', () => { + it('returns a {top/left/width/height} object describing the rectangle between two screen positions, even if they are not on screen', async () => { + const element = buildTextEditorElement() + const editor = element.getModel() + editor.update({autoHeight: false}) + element.getModel().setText('xxxxxxxxxxxxxxxxxxxxxx\n'.repeat(20)) + element.style.height = '120px' + await element.getNextUpdatePromise() + element.setScrollTop(80) + await element.getNextUpdatePromise() + expect(element.getVisibleRowRange()).toEqual([4, 11]) + + expect(element.pixelRectForScreenRange([[2, 3], [13, 11]])).toEqual({top: 34, left: 22, height: 204, width: 57}) + }) + }) + describe('events', () => { let element = null diff --git a/src/text-editor-element.js b/src/text-editor-element.js index 947bf3a8e82..bb530cbb966 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -1,4 +1,4 @@ -const {Emitter} = require('atom') +const {Emitter, Range} = require('atom') const Grim = require('grim') const TextEditorComponent = require('./text-editor-component') const dedent = require('dedent') @@ -248,6 +248,31 @@ class TextEditorElement extends HTMLElement { return this.getComponent().screenPositionForPixelPositionSync(pixelPosition) } + pixelRectForScreenRange (range) { + range = Range.fromObject(range) + + const start = this.pixelPositionForScreenPosition(range.start) + const end = this.pixelPositionForScreenPosition(range.end) + const lineHeight = this.getComponent().getLineHeight() + + console.log(start, end); + + return { + top: start.top, + left: start.left, + height: end.top + lineHeight - start.top, + width: end.left - start.left + } + } + + pixelRangeForScreenRange (range) { + range = Range.fromObject(range) + return { + start: this.pixelPositionForScreenPosition(range.start), + end: this.pixelPositionForScreenPosition(range.end) + } + } + getComponent () { if (!this.component) { this.component = new TextEditorComponent({ From 1ca4c69c873dffaccc697f5b9f5500bf961a7dad Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 24 Apr 2017 04:45:13 -0600 Subject: [PATCH 308/403] WIP: Start extracting gutter component --- spec/text-editor-component-spec.js | 8 +- src/text-editor-component.js | 201 ++++++++++++++++------------- 2 files changed, 115 insertions(+), 94 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index b0601f3115e..cd72109c25d 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -118,7 +118,7 @@ describe('TextEditorComponent', () => { it('gives the line number tiles an explicit width and height so their layout can be strictly contained', async () => { const {component, element, editor} = buildComponent({rowsPerTile: 3}) - const lineNumberGutterElement = component.refs.lineNumberGutter.element + const lineNumberGutterElement = component.refs.gutterContainer.refs.lineNumberGutter.element expect(lineNumberGutterElement.offsetHeight).toBe(component.getScrollHeight()) for (const child of lineNumberGutterElement.children) { @@ -1421,7 +1421,7 @@ describe('TextEditorComponent', () => { editor.addGutter({name: 'c', priority: 0}) await component.getNextUpdatePromise() - const gutters = component.refs.gutterContainer.querySelectorAll('.gutter') + const gutters = component.refs.gutterContainer.element.querySelectorAll('.gutter') expect(Array.from(gutters).map((g) => g.getAttribute('gutter-name'))).toEqual([ 'a', 'b', 'c', 'line-number', 'd', 'e' ]) @@ -1432,7 +1432,7 @@ describe('TextEditorComponent', () => { const {scrollContainer, gutterContainer} = component.refs function checkScrollContainerLeft () { - expect(scrollContainer.getBoundingClientRect().left).toBe(Math.round(gutterContainer.getBoundingClientRect().right)) + expect(scrollContainer.getBoundingClientRect().left).toBe(Math.round(gutterContainer.element.getBoundingClientRect().right)) } checkScrollContainerLeft() @@ -3175,7 +3175,7 @@ function clientPositionForCharacter (component, row, column) { } function lineNumberNodeForScreenRow (component, row) { - const gutterElement = component.refs.lineNumberGutter.element + const gutterElement = component.refs.gutterContainer.refs.lineNumberGutter.element const tileStartRow = component.tileStartRowForRow(row) const tileIndex = component.tileIndexForTileStartRow(tileStartRow) return gutterElement.children[tileIndex + 1].children[row - tileStartRow] diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 6d3a6f13101..2d06344018c 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -123,7 +123,6 @@ class TextEditorComponent { this.pendingScrollLeftColumn = this.props.initialScrollLeftColumn this.measuredContent = false - this.gutterContainerVnode = null this.cursorsVnode = null this.placeholderTextVnode = null @@ -311,7 +310,7 @@ class TextEditorComponent { measureContentDuringUpdateSync () { if (this.remeasureGutterDimensions) { if (this.measureGutterDimensions()) { - this.gutterContainerVnode = null + // TODO: Ensure we update the gutter container in the second phase of the update } this.remeasureGutterDimensions = false } @@ -434,82 +433,13 @@ class TextEditorComponent { } renderGutterContainer () { - if (this.props.model.isMini()) return null - - if (!this.measuredContent || !this.gutterContainerVnode) { - const innerStyle = { - willChange: 'transform', - display: 'flex' - } - - let scrollHeight - if (this.measurements) { - innerStyle.transform = `translateY(${-this.getScrollTop()}px)` - scrollHeight = this.getScrollHeight() - } - - this.gutterContainerVnode = $.div( - { - ref: 'gutterContainer', - key: 'gutterContainer', - className: 'gutter-container', - style: { - position: 'relative', - zIndex: 1, - backgroundColor: 'inherit' - } - }, - $.div({style: innerStyle}, - this.guttersToRender.map((gutter) => { - if (gutter.name === 'line-number') { - return this.renderLineNumberGutter(gutter) - } else { - return $(CustomGutterComponent, { - key: gutter, - element: gutter.getElement(), - name: gutter.name, - visible: gutter.isVisible(), - height: scrollHeight, - decorations: this.decorationsToRender.customGutter.get(gutter.name) - }) - } - }) - ) - ) - } - - return this.gutterContainerVnode - } - - renderLineNumberGutter (gutter) { - if (!this.props.model.isLineNumberGutterVisible()) return null - - if (this.measurements) { - const {maxDigits, keys, bufferRows, softWrappedFlags, foldableFlags} = this.lineNumbersToRender - return $(LineNumberGutterComponent, { - ref: 'lineNumberGutter', - element: gutter.getElement(), - parentComponent: this, - startRow: this.getRenderedStartRow(), - endRow: this.getRenderedEndRow(), - rowsPerTile: this.getRowsPerTile(), - maxDigits: maxDigits, - keys: keys, - bufferRows: bufferRows, - softWrappedFlags: softWrappedFlags, - foldableFlags: foldableFlags, - decorations: this.decorationsToRender.lineNumbers, - blockDecorations: this.decorationsToRender.blocks, - didMeasureVisibleBlockDecoration: this.didMeasureVisibleBlockDecoration, - height: this.getScrollHeight(), - width: this.getLineNumberGutterWidth(), - lineHeight: this.getLineHeight() - }) + if (this.props.model.isMini()) { + return null } else { - return $(LineNumberGutterComponent, { - ref: 'lineNumberGutter', - element: gutter.getElement(), - maxDigits: this.lineNumbersToRender.maxDigits + return $(GutterContainer, { + ref: 'gutterContainer', + key: 'gutterContainer', + rootComponent: this }) } } @@ -1253,7 +1183,7 @@ class TextEditorComponent { if (this.refs.gutterContainer) { this.gutterContainerResizeObserver = new ResizeObserver(this.didResizeGutterContainer.bind(this)) - this.gutterContainerResizeObserver.observe(this.refs.gutterContainer) + this.gutterContainerResizeObserver.observe(this.refs.gutterContainer.element) } this.overlayComponents.forEach((component) => component.didAttach()) @@ -1404,7 +1334,7 @@ class TextEditorComponent { if (this.measureGutterDimensions()) { this.gutterContainerResizeObserver.disconnect() this.scheduleUpdate() - process.nextTick(() => { this.gutterContainerResizeObserver.observe(this.refs.gutterContainer) }) + process.nextTick(() => { this.gutterContainerResizeObserver.observe(this.refs.gutterContainer.element) }) } } @@ -1930,7 +1860,7 @@ class TextEditorComponent { let dimensionsChanged = false if (this.refs.gutterContainer) { - const gutterContainerWidth = this.refs.gutterContainer.offsetWidth + const gutterContainerWidth = this.refs.gutterContainer.element.offsetWidth if (gutterContainerWidth !== this.measurements.gutterContainerWidth) { dimensionsChanged = true this.measurements.gutterContainerWidth = gutterContainerWidth @@ -1939,8 +1869,8 @@ class TextEditorComponent { this.measurements.gutterContainerWidth = 0 } - if (this.refs.lineNumberGutter) { - const lineNumberGutterWidth = this.refs.lineNumberGutter.element.offsetWidth + if (this.refs.gutterContainer && this.refs.gutterContainer.refs.lineNumberGutter) { + const lineNumberGutterWidth = this.refs.gutterContainer.refs.lineNumberGutter.element.offsetWidth if (lineNumberGutterWidth !== this.measurements.lineNumberGutterWidth) { dimensionsChanged = true this.measurements.lineNumberGutterWidth = lineNumberGutterWidth @@ -2679,6 +2609,97 @@ class DummyScrollbarComponent { } } +class GutterContainer { + constructor (props) { + this.props = props + etch.initialize(this) + } + + update (props) { + this.props = props + etch.updateSync(this) + } + + render () { + const {rootComponent} = this.props + + const innerStyle = { + willChange: 'transform', + display: 'flex' + } + + let scrollHeight + if (rootComponent.measurements) { + innerStyle.transform = `translateY(${-rootComponent.getScrollTop()}px)` + scrollHeight = rootComponent.getScrollHeight() + } + + return $.div( + { + ref: 'gutterContainer', + key: 'gutterContainer', + className: 'gutter-container', + style: { + position: 'relative', + zIndex: 1, + backgroundColor: 'inherit' + } + }, + $.div({style: innerStyle}, + rootComponent.guttersToRender.map((gutter) => { + if (gutter.name === 'line-number') { + return this.renderLineNumberGutter(gutter) + } else { + return $(CustomGutterComponent, { + key: gutter, + element: gutter.getElement(), + name: gutter.name, + visible: gutter.isVisible(), + height: scrollHeight, + decorations: rootComponent.decorationsToRender.customGutter.get(gutter.name) + }) + } + }) + ) + ) + } + + renderLineNumberGutter (gutter) { + const {rootComponent} = this.props + + if (!rootComponent.props.model.isLineNumberGutterVisible()) return null + + if (rootComponent.measurements) { + const {maxDigits, keys, bufferRows, softWrappedFlags, foldableFlags} = rootComponent.lineNumbersToRender + return $(LineNumberGutterComponent, { + ref: 'lineNumberGutter', + element: gutter.getElement(), + rootComponent: rootComponent, + startRow: rootComponent.getRenderedStartRow(), + endRow: rootComponent.getRenderedEndRow(), + rowsPerTile: rootComponent.getRowsPerTile(), + maxDigits: maxDigits, + keys: keys, + bufferRows: bufferRows, + softWrappedFlags: softWrappedFlags, + foldableFlags: foldableFlags, + decorations: rootComponent.decorationsToRender.lineNumbers, + blockDecorations: rootComponent.decorationsToRender.blocks, + didMeasureVisibleBlockDecoration: rootComponent.didMeasureVisibleBlockDecoration, + height: rootComponent.getScrollHeight(), + width: rootComponent.getLineNumberGutterWidth(), + lineHeight: rootComponent.getLineHeight() + }) + } else { + return $(LineNumberGutterComponent, { + ref: 'lineNumberGutter', + element: gutter.getElement(), + maxDigits: rootComponent.lineNumbersToRender.maxDigits + }) + } + } +} + class LineNumberGutterComponent { constructor (props) { this.props = props @@ -2697,14 +2718,14 @@ class LineNumberGutterComponent { render () { const { - parentComponent, height, width, lineHeight, startRow, endRow, rowsPerTile, + rootComponent, height, width, lineHeight, startRow, endRow, rowsPerTile, maxDigits, keys, bufferRows, softWrappedFlags, foldableFlags, decorations } = this.props let children = null if (bufferRows) { - const renderedTileCount = parentComponent.getRenderedTileCount() + const renderedTileCount = rootComponent.getRenderedTileCount() children = new Array(renderedTileCount) for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow = tileStartRow + rowsPerTile) { @@ -2733,8 +2754,8 @@ class LineNumberGutterComponent { dataset: {bufferRow} } if (row === 0 || i > 0) { - let currentRowTop = parentComponent.pixelPositionAfterBlocksForRow(row) - let previousRowBottom = parentComponent.pixelPositionAfterBlocksForRow(row - 1) + lineHeight + let currentRowTop = rootComponent.pixelPositionAfterBlocksForRow(row) + let previousRowBottom = rootComponent.pixelPositionAfterBlocksForRow(row - 1) + lineHeight if (currentRowTop > previousRowBottom) { lineNumberProps.style.marginTop = (currentRowTop - previousRowBottom) + 'px' } @@ -2746,9 +2767,9 @@ class LineNumberGutterComponent { ) } - const tileIndex = parentComponent.tileIndexForTileStartRow(tileStartRow) - const tileTop = parentComponent.pixelPositionBeforeBlocksForRow(tileStartRow) - const tileBottom = parentComponent.pixelPositionBeforeBlocksForRow(tileEndRow) + const tileIndex = rootComponent.tileIndexForTileStartRow(tileStartRow) + const tileTop = rootComponent.pixelPositionBeforeBlocksForRow(tileStartRow) + const tileBottom = rootComponent.pixelPositionBeforeBlocksForRow(tileEndRow) const tileHeight = tileBottom - tileTop children[tileIndex] = $.div({ @@ -2841,7 +2862,7 @@ class LineNumberGutterComponent { } didMouseDown (event) { - this.props.parentComponent.didMouseDownOnLineNumberGutter(event) + this.props.rootComponent.didMouseDownOnLineNumberGutter(event) } } From 656cabda0f46136ee46528313cbb94ecaff6127c Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 24 Apr 2017 05:02:42 -0600 Subject: [PATCH 309/403] Initialize all measurements to 0 --- src/text-editor-component.js | 68 ++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 2d06344018c..310a3197349 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -71,7 +71,21 @@ class TextEditorComponent { this.didMouseDownOnContent = this.didMouseDownOnContent.bind(this) this.lineTopIndex = new LineTopIndex() this.updateScheduled = false - this.measurements = null + this.hasInitialMeasurements = false + this.measurements = { + lineHeight: 0, + baseCharacterWidth: 0, + doubleWidthCharacterWidth: 0, + halfWidthCharacterWidth: 0, + koreanCharacterWidth: 0, + gutterContainerWidth: 0, + lineNumberGutterWidth: 0, + clientContainerHeight: 0, + clientContainerWidth: 0, + verticalScrollbarWidth: 0, + horizontalScrollbarHeight: 0, + longestLineWidth: 0 + } this.visible = false this.cursorsBlinking = false this.cursorsBlinkedOff = false @@ -368,7 +382,7 @@ class TextEditorComponent { let clientContainerHeight = '100%' let clientContainerWidth = '100%' - if (this.measurements) { + if (this.hasInitialMeasurements) { if (model.getAutoHeight()) { clientContainerHeight = this.getContentHeight() if (this.isHorizontalScrollbarVisible()) clientContainerHeight += this.getHorizontalScrollbarHeight() @@ -454,7 +468,7 @@ class TextEditorComponent { backgroundColor: 'inherit' } - if (this.measurements) { + if (this.hasInitialMeasurements) { style.left = this.getGutterContainerWidth() + 'px' style.width = this.getScrollContainerWidth() + 'px' } @@ -478,7 +492,7 @@ class TextEditorComponent { overflow: 'hidden', backgroundColor: 'inherit' } - if (this.measurements) { + if (this.hasInitialMeasurements) { style.width = this.getScrollWidth() + 'px' style.height = this.getScrollHeight() + 'px' style.willChange = 'transform' @@ -703,7 +717,7 @@ class TextEditorComponent { let scrollHeight, scrollTop, horizontalScrollbarHeight let scrollWidth, scrollLeft, verticalScrollbarWidth, forceScrollbarVisible - if (this.measurements) { + if (this.hasInitialMeasurements) { scrollHeight = this.getScrollHeight() scrollWidth = this.getScrollWidth() scrollTop = this.getScrollTop() @@ -1216,7 +1230,7 @@ class TextEditorComponent { didShow () { if (!this.visible && this.isVisible()) { this.visible = true - if (!this.measurements) this.performInitialMeasurements() + if (!this.hasInitialMeasurements) this.measureDimensions() this.props.model.setVisible(true) this.updateSync() this.flushPendingLogicalScrollPosition() @@ -1824,11 +1838,6 @@ class TextEditorComponent { return marginInBaseCharacters * this.getBaseCharacterWidth() } - performInitialMeasurements () { - this.measurements = {} - this.measureDimensions() - } - // This method exists because it existed in the previous implementation and some // package tests relied on it measureDimensions () { @@ -1837,6 +1846,7 @@ class TextEditorComponent { this.measureClientContainerHeight() this.measureClientContainerWidth() this.measureScrollbarDimensions() + this.hasInitialMeasurements = true } measureCharacterDimensions () { @@ -1883,8 +1893,6 @@ class TextEditorComponent { } measureClientContainerHeight () { - if (!this.measurements) return false - const clientContainerHeight = this.refs.clientContainer.offsetHeight if (clientContainerHeight !== this.measurements.clientContainerHeight) { this.measurements.clientContainerHeight = clientContainerHeight @@ -1895,8 +1903,6 @@ class TextEditorComponent { } measureClientContainerWidth () { - if (!this.measurements) return false - const clientContainerWidth = this.refs.clientContainer.offsetWidth if (clientContainerWidth !== this.measurements.clientContainerWidth) { this.measurements.clientContainerWidth = clientContainerWidth @@ -2215,7 +2221,7 @@ class TextEditorComponent { } getBaseCharacterWidth () { - return this.measurements ? this.measurements.baseCharacterWidth : null + return this.measurements.baseCharacterWidth } getLongestLineWidth () { @@ -2317,7 +2323,7 @@ class TextEditorComponent { } getGutterContainerWidth () { - return (this.measurements) ? this.measurements.gutterContainerWidth : 0 + return this.measurements.gutterContainerWidth } getLineNumberGutterWidth () { @@ -2368,24 +2374,18 @@ class TextEditorComponent { } getFirstVisibleRow () { - if (this.measurements) { - return this.rowForPixelPosition(this.getScrollTop()) - } + return this.rowForPixelPosition(this.getScrollTop()) } getLastVisibleRow () { - if (this.measurements) { - return Math.min( - this.props.model.getApproximateScreenLineCount() - 1, - this.rowForPixelPosition(this.getScrollBottom()) - ) - } + return Math.min( + this.props.model.getApproximateScreenLineCount() - 1, + this.rowForPixelPosition(this.getScrollBottom()) + ) } getFirstVisibleColumn () { - if (this.measurements) { - return Math.floor(this.getScrollLeft() / this.getBaseCharacterWidth()) - } + return Math.floor(this.getScrollLeft() / this.getBaseCharacterWidth()) } getVisibleTileCount () { @@ -2450,7 +2450,7 @@ class TextEditorComponent { } setScrollTopRow (scrollTopRow, scheduleUpdate = true) { - if (this.measurements) { + if (this.hasInitialMeasurements) { const didScroll = this.setScrollTop(this.pixelPositionBeforeBlocksForRow(scrollTopRow)) if (didScroll && scheduleUpdate) { this.scheduleUpdate() @@ -2463,7 +2463,7 @@ class TextEditorComponent { } getScrollTopRow () { - if (this.measurements) { + if (this.hasInitialMeasurements) { return this.rowForPixelPosition(this.getScrollTop()) } else { return this.pendingScrollTopRow || 0 @@ -2471,7 +2471,7 @@ class TextEditorComponent { } setScrollLeftColumn (scrollLeftColumn, scheduleUpdate = true) { - if (this.measurements && this.getLongestLineWidth() != null) { + if (this.hasInitialMeasurements && this.getLongestLineWidth() != null) { const didScroll = this.setScrollLeft(scrollLeftColumn * this.getBaseCharacterWidth()) if (didScroll && scheduleUpdate) { this.scheduleUpdate() @@ -2484,7 +2484,7 @@ class TextEditorComponent { } getScrollLeftColumn () { - if (this.measurements) { + if (this.hasInitialMeasurements && this.getLongestLineWidth() != null) { return Math.floor(this.getScrollLeft() / this.getBaseCharacterWidth()) } else { return this.pendingScrollLeftColumn || 0 @@ -2669,7 +2669,7 @@ class GutterContainer { if (!rootComponent.props.model.isLineNumberGutterVisible()) return null - if (rootComponent.measurements) { + if (rootComponent.hasInitialMeasurements) { const {maxDigits, keys, bufferRows, softWrappedFlags, foldableFlags} = rootComponent.lineNumbersToRender return $(LineNumberGutterComponent, { ref: 'lineNumberGutter', From 8c7f4d91f8ca9ea1769273aed958807fb56b8cf3 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 24 Apr 2017 05:09:20 -0600 Subject: [PATCH 310/403] :art: --- src/text-editor-component.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 310a3197349..4d4d40659f2 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -450,7 +450,7 @@ class TextEditorComponent { if (this.props.model.isMini()) { return null } else { - return $(GutterContainer, { + return $(GutterContainerComponent, { ref: 'gutterContainer', key: 'gutterContainer', rootComponent: this @@ -2609,7 +2609,7 @@ class DummyScrollbarComponent { } } -class GutterContainer { +class GutterContainerComponent { constructor (props) { this.props = props etch.initialize(this) From 7d7a6ab507d0c0065dce5c8addacbe2c258eddc0 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 24 Apr 2017 05:39:51 -0600 Subject: [PATCH 311/403] Pass props to GutterContainerComponent instead of reaching up to parent There are still a few rootComponent references remaining in the LineNumberGutterComponent. These should be removed and instead we should consult this data when constructing the line numbers to render. --- src/text-editor-component.js | 59 ++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 4d4d40659f2..9fc96dd7157 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -453,7 +453,20 @@ class TextEditorComponent { return $(GutterContainerComponent, { ref: 'gutterContainer', key: 'gutterContainer', - rootComponent: this + rootComponent: this, + hasInitialMeasurements: this.hasInitialMeasurements, + scrollTop: this.getScrollTop(), + scrollHeight: this.getScrollHeight(), + lineNumberGutterWidth: this.getLineNumberGutterWidth(), + lineHeight: this.getLineHeight(), + renderedStartRow: this.getRenderedStartRow(), + renderedEndRow: this.getRenderedEndRow(), + rowsPerTile: this.getRowsPerTile(), + guttersToRender: this.guttersToRender, + decorationsToRender: this.decorationsToRender, + isLineNumberGutterVisible: this.props.model.isLineNumberGutterVisible(), + lineNumbersToRender: this.lineNumbersToRender, + didMeasureVisibleBlockDecoration: this.didMeasureVisibleBlockDecoration }) } } @@ -2621,17 +2634,15 @@ class GutterContainerComponent { } render () { - const {rootComponent} = this.props + const {hasInitialMeasurements, scrollTop, scrollHeight, guttersToRender, decorationsToRender} = this.props const innerStyle = { willChange: 'transform', display: 'flex' } - let scrollHeight - if (rootComponent.measurements) { - innerStyle.transform = `translateY(${-rootComponent.getScrollTop()}px)` - scrollHeight = rootComponent.getScrollHeight() + if (hasInitialMeasurements) { + innerStyle.transform = `translateY(${-scrollTop}px)` } return $.div( @@ -2646,7 +2657,7 @@ class GutterContainerComponent { } }, $.div({style: innerStyle}, - rootComponent.guttersToRender.map((gutter) => { + guttersToRender.map((gutter) => { if (gutter.name === 'line-number') { return this.renderLineNumberGutter(gutter) } else { @@ -2656,7 +2667,7 @@ class GutterContainerComponent { name: gutter.name, visible: gutter.isVisible(), height: scrollHeight, - decorations: rootComponent.decorationsToRender.customGutter.get(gutter.name) + decorations: decorationsToRender.customGutter.get(gutter.name) }) } }) @@ -2665,36 +2676,40 @@ class GutterContainerComponent { } renderLineNumberGutter (gutter) { - const {rootComponent} = this.props + const { + rootComponent, isLineNumberGutterVisible, hasInitialMeasurements, lineNumbersToRender, + renderedStartRow, renderedEndRow, rowsPerTile, decorationsToRender, didMeasureVisibleBlockDecoration, + scrollHeight, lineNumberGutterWidth, lineHeight + } = this.props - if (!rootComponent.props.model.isLineNumberGutterVisible()) return null + if (!isLineNumberGutterVisible) return null - if (rootComponent.hasInitialMeasurements) { - const {maxDigits, keys, bufferRows, softWrappedFlags, foldableFlags} = rootComponent.lineNumbersToRender + if (hasInitialMeasurements) { + const {maxDigits, keys, bufferRows, softWrappedFlags, foldableFlags} = lineNumbersToRender return $(LineNumberGutterComponent, { ref: 'lineNumberGutter', element: gutter.getElement(), rootComponent: rootComponent, - startRow: rootComponent.getRenderedStartRow(), - endRow: rootComponent.getRenderedEndRow(), - rowsPerTile: rootComponent.getRowsPerTile(), + startRow: renderedStartRow, + endRow: renderedEndRow, + rowsPerTile: rowsPerTile, maxDigits: maxDigits, keys: keys, bufferRows: bufferRows, softWrappedFlags: softWrappedFlags, foldableFlags: foldableFlags, - decorations: rootComponent.decorationsToRender.lineNumbers, - blockDecorations: rootComponent.decorationsToRender.blocks, - didMeasureVisibleBlockDecoration: rootComponent.didMeasureVisibleBlockDecoration, - height: rootComponent.getScrollHeight(), - width: rootComponent.getLineNumberGutterWidth(), - lineHeight: rootComponent.getLineHeight() + decorations: decorationsToRender.lineNumbers, + blockDecorations: decorationsToRender.blocks, + didMeasureVisibleBlockDecoration: didMeasureVisibleBlockDecoration, + height: scrollHeight, + width: lineNumberGutterWidth, + lineHeight: lineHeight }) } else { return $(LineNumberGutterComponent, { ref: 'lineNumberGutter', element: gutter.getElement(), - maxDigits: rootComponent.lineNumbersToRender.maxDigits + maxDigits: lineNumbersToRender.maxDigits }) } } From b23dcb7b9f3c606a349d6cc9f88a5988f83e001b Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 24 Apr 2017 05:43:37 -0600 Subject: [PATCH 312/403] Eliminate caching of linesVnode --- src/text-editor-component.js | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 9fc96dd7157..405fb2ee1d1 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2962,15 +2962,11 @@ class CustomGutterDecorationComponent { class LinesTileComponent { constructor (props) { this.props = props - this.linesVnode = null etch.initialize(this) } update (newProps) { if (this.shouldUpdate(newProps)) { - if (newProps.width !== this.props.width) { - this.linesVnode = null - } this.props = newProps etch.updateSync(this) } @@ -3033,21 +3029,17 @@ class LinesTileComponent { lineNodesByScreenLineId, textNodesByScreenLineId } = this.props - if (!measuredContent || !this.linesVnode) { - this.linesVnode = $(LinesComponent, { - height, - width, - tileStartRow, - screenLines, - lineDecorations, - blockDecorations, - displayLayer, - lineNodesByScreenLineId, - textNodesByScreenLineId - }) - } - - return this.linesVnode + return $(LinesComponent, { + height, + width, + tileStartRow, + screenLines, + lineDecorations, + blockDecorations, + displayLayer, + lineNodesByScreenLineId, + textNodesByScreenLineId + }) } shouldUpdate (newProps) { From 82cdf80f2540849585143c7421ff7715245407ac Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 24 Apr 2017 06:01:30 -0600 Subject: [PATCH 313/403] Extract CursorsAndInputComponent --- spec/text-editor-component-spec.js | 18 +-- src/text-editor-component.js | 232 +++++++++++++++++------------ 2 files changed, 149 insertions(+), 101 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index cd72109c25d..2f319863e93 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -374,7 +374,7 @@ describe('TextEditorComponent', () => { it('places the hidden input element at the location of the last cursor if it is visible', async () => { const {component, element, editor} = buildComponent({height: 60, width: 120, rowsPerTile: 2}) - const {hiddenInput} = component.refs + const {hiddenInput} = component.refs.cursorsAndInput.refs setScrollTop(component, 100) await setScrollLeft(component, 40) @@ -594,7 +594,7 @@ describe('TextEditorComponent', () => { it('focuses the hidden input element and adds the is-focused class when focused', async () => { const {component, element, editor} = buildComponent() - const {hiddenInput} = component.refs + const {hiddenInput} = component.refs.cursorsAndInput.refs expect(document.activeElement).not.toBe(hiddenInput) element.focus() @@ -614,7 +614,7 @@ describe('TextEditorComponent', () => { it('updates the component when the hidden input is focused directly', async () => { const {component, element, editor} = buildComponent() - const {hiddenInput} = component.refs + const {hiddenInput} = component.refs.cursorsAndInput.refs expect(element.classList.contains('is-focused')).toBe(false) expect(document.activeElement).not.toBe(hiddenInput) @@ -629,7 +629,7 @@ describe('TextEditorComponent', () => { parent.appendChild(element) parent.didAttach = () => element.focus() jasmine.attachToDOM(parent) - expect(document.activeElement).toBe(component.refs.hiddenInput) + expect(document.activeElement).toBe(component.refs.cursorsAndInput.refs.hiddenInput) }) it('gracefully handles a focus event that occurs prior to detecting the element has become visible', async () => { @@ -640,7 +640,7 @@ describe('TextEditorComponent', () => { element.focus() await component.getNextUpdatePromise() - expect(document.activeElement).toBe(component.refs.hiddenInput) + expect(document.activeElement).toBe(component.refs.cursorsAndInput.refs.hiddenInput) }) it('emits blur events only when focus shifts to something other than the editor itself or its hidden input', () => { @@ -2731,7 +2731,7 @@ describe('TextEditorComponent', () => { component.didKeydown({code: 'Enter'}) component.didCompositionUpdate({data: 'á'}) component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) - component.didCompositionEnd({data: 'á', target: component.refs.hiddenInput}) + component.didCompositionEnd({data: 'á', target: component.refs.cursorsAndInput.refs.hiddenInput}) component.didKeyup({code: 'Enter'}) expect(editor.getText()).toBe('xá') // Ensure another "a" can be typed correctly. @@ -2763,7 +2763,7 @@ describe('TextEditorComponent', () => { component.didKeydown({code: 'Escape'}) component.didCompositionUpdate({data: 'a'}) component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didCompositionEnd({data: 'a', target: component.refs.hiddenInput}) + component.didCompositionEnd({data: 'a', target: component.refs.cursorsAndInput.refs.hiddenInput}) component.didKeyup({code: 'Escape'}) expect(editor.getText()).toBe('xa') // Ensure another "a" can be typed correctly. @@ -2798,7 +2798,7 @@ describe('TextEditorComponent', () => { component.didKeydown({code: 'Escape'}) component.didCompositionUpdate({data: 'a'}) component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didCompositionEnd({data: 'a', target: component.refs.hiddenInput}) + component.didCompositionEnd({data: 'a', target: component.refs.cursorsAndInput.refs.hiddenInput}) component.didKeyup({code: 'Escape'}) expect(editor.getText()).toBe('xoa') // Ensure another "a" can be typed correctly. @@ -2828,7 +2828,7 @@ describe('TextEditorComponent', () => { expect(editor.getText()).toBe('xá') component.didCompositionUpdate({data: 'á'}) component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) - component.didCompositionEnd({data: 'á', target: component.refs.hiddenInput}) + component.didCompositionEnd({data: 'á', target: component.refs.cursorsAndInput.refs.hiddenInput}) expect(editor.getText()).toBe('xá') // Ensure another "a" can be typed correctly. component.didKeydown({code: 'KeyA'}) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 405fb2ee1d1..aff7da73bca 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -66,6 +66,16 @@ class TextEditorComponent { this.refs = {} this.updateSync = this.updateSync.bind(this) + this.didBlurHiddenInput = this.didBlurHiddenInput.bind(this) + this.didFocusHiddenInput = this.didFocusHiddenInput.bind(this) + this.didTextInput = this.didTextInput.bind(this) + this.didKeydown = this.didKeydown.bind(this) + this.didKeyup = this.didKeyup.bind(this) + this.didKeypress = this.didKeypress.bind(this) + this.didCompositionStart = this.didCompositionStart.bind(this) + this.didCompositionUpdate = this.didCompositionUpdate.bind(this) + this.didCompositionEnd = this.didCompositionEnd.bind(this) + this.updatedSynchronously = this.props.updatedSynchronously this.didScrollDummyScrollbar = this.didScrollDummyScrollbar.bind(this) this.didMouseDownOnContent = this.didMouseDownOnContent.bind(this) @@ -137,7 +147,6 @@ class TextEditorComponent { this.pendingScrollLeftColumn = this.props.initialScrollLeftColumn this.measuredContent = false - this.cursorsVnode = null this.placeholderTextVnode = null this.queryGuttersToRender() @@ -210,7 +219,7 @@ class TextEditorComponent { const onlyBlinkingCursors = this.nextUpdateOnlyBlinksCursors this.nextUpdateOnlyBlinksCursors = null if (useScheduler && onlyBlinkingCursors) { - this.updateCursorBlinkSync() + this.refs.cursorsAndInput.updateCursorBlinkSync(this.cursorsBlinkedOff) if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise() return } @@ -366,12 +375,6 @@ class TextEditorComponent { } } - updateCursorBlinkSync () { - const className = this.getCursorsClassName() - this.refs.cursors.className = className - this.cursorsVnode.props.className = className - } - render () { const {model} = this.props const style = {} @@ -600,49 +603,25 @@ class TextEditorComponent { } renderCursorsAndInput () { - if (this.measuredContent) { - const className = this.getCursorsClassName() - const cursorHeight = this.getLineHeight() + 'px' - - const children = [this.renderHiddenInput()] - for (let i = 0; i < this.decorationsToRender.cursors.length; i++) { - const {pixelLeft, pixelTop, pixelWidth, className: extraCursorClassName, style: extraCursorStyle} = this.decorationsToRender.cursors[i] - let cursorClassName = 'cursor' - if (extraCursorClassName) cursorClassName += ' ' + extraCursorClassName - - const cursorStyle = { - height: cursorHeight, - width: pixelWidth + 'px', - transform: `translate(${pixelLeft}px, ${pixelTop}px)` - } - if (extraCursorStyle) Object.assign(cursorStyle, extraCursorStyle) - - children.push($.div({ - className: cursorClassName, - style: cursorStyle - })) - } - - this.cursorsVnode = $.div({ - key: 'cursors', - ref: 'cursors', - className, - style: { - position: 'absolute', - contain: 'strict', - zIndex: 1, - width: this.getScrollWidth() + 'px', - height: this.getScrollHeight() + 'px', - pointerEvents: 'none' - } - }, children) - } - - return this.cursorsVnode - } - - getCursorsClassName () { - return this.cursorsBlinkedOff ? 'cursors blink-off' : 'cursors' + return $(CursorsAndInputComponent, { + ref: 'cursorsAndInput', + key: 'cursorsAndInput', + didBlurHiddenInput: this.didBlurHiddenInput, + didFocusHiddenInput: this.didFocusHiddenInput, + didTextInput: this.didTextInput, + didKeydown: this.didKeydown, + didKeyup: this.didKeyup, + didKeypress: this.didKeypress, + didCompositionStart: this.didCompositionStart, + didCompositionUpdate: this.didCompositionUpdate, + didCompositionEnd: this.didCompositionEnd, + lineHeight: this.getLineHeight(), + scrollHeight: this.getScrollHeight(), + scrollWidth: this.getScrollWidth(), + decorationsToRender: this.decorationsToRender, + cursorsBlinkedOff: this.cursorsBlinkedOff, + hiddenInputPosition: this.hiddenInputPosition + }) } renderPlaceholderText () { @@ -686,45 +665,6 @@ class TextEditorComponent { }) } - renderHiddenInput () { - let top, left - if (this.hiddenInputPosition) { - top = this.hiddenInputPosition.pixelTop - left = this.hiddenInputPosition.pixelLeft - } else { - top = 0 - left = 0 - } - - return $.input({ - ref: 'hiddenInput', - key: 'hiddenInput', - className: 'hidden-input', - on: { - blur: this.didBlurHiddenInput, - focus: this.didFocusHiddenInput, - textInput: this.didTextInput, - keydown: this.didKeydown, - keyup: this.didKeyup, - keypress: this.didKeypress, - compositionstart: this.didCompositionStart, - compositionupdate: this.didCompositionUpdate, - compositionend: this.didCompositionEnd - }, - tabIndex: -1, - style: { - position: 'absolute', - width: '1px', - height: this.getLineHeight() + 'px', - top: top + 'px', - left: left + 'px', - opacity: 0, - padding: 0, - border: 0 - } - }) - } - renderDummyScrollbars () { if (this.shouldRenderDummyScrollbars && !this.props.model.isMini()) { let scrollHeight, scrollTop, horizontalScrollbarHeight @@ -1280,7 +1220,7 @@ class TextEditorComponent { // Transfer focus to the hidden input, but first ensure the input is in the // visible part of the scrolled content to avoid the browser trying to // auto-scroll to the form-field. - const {hiddenInput} = this.refs + const {hiddenInput} = this.refs.cursorsAndInput.refs hiddenInput.style.top = this.getScrollTop() + 'px' hiddenInput.style.left = this.getScrollLeft() + 'px' @@ -1301,7 +1241,7 @@ class TextEditorComponent { // listener to be fired, even if other listeners are bound before creating // the component. didBlur (event) { - if (event.relatedTarget === this.refs.hiddenInput) { + if (event.relatedTarget === this.refs.cursorsAndInput.refs.hiddenInput) { event.stopImmediatePropagation() } } @@ -2959,6 +2899,114 @@ class CustomGutterDecorationComponent { } } +class CursorsAndInputComponent { + constructor (props) { + this.props = props + etch.initialize(this) + } + + update (props) { + this.props = props + etch.updateSync(this) + } + + updateCursorBlinkSync (cursorsBlinkedOff) { + this.props.cursorsBlinkedOff = cursorsBlinkedOff + const className = this.getCursorsClassName() + this.refs.cursors.className = className + this.virtualNode.props.className = className + } + + render () { + const {lineHeight, decorationsToRender, scrollHeight, scrollWidth} = this.props + + const className = this.getCursorsClassName() + const cursorHeight = lineHeight + 'px' + + const children = [this.renderHiddenInput()] + for (let i = 0; i < decorationsToRender.cursors.length; i++) { + const {pixelLeft, pixelTop, pixelWidth, className: extraCursorClassName, style: extraCursorStyle} = decorationsToRender.cursors[i] + let cursorClassName = 'cursor' + if (extraCursorClassName) cursorClassName += ' ' + extraCursorClassName + + const cursorStyle = { + height: cursorHeight, + width: pixelWidth + 'px', + transform: `translate(${pixelLeft}px, ${pixelTop}px)` + } + if (extraCursorStyle) Object.assign(cursorStyle, extraCursorStyle) + + children.push($.div({ + className: cursorClassName, + style: cursorStyle + })) + } + + return $.div({ + key: 'cursors', + ref: 'cursors', + className, + style: { + position: 'absolute', + contain: 'strict', + zIndex: 1, + width: scrollWidth + 'px', + height: scrollHeight + 'px', + pointerEvents: 'none' + } + }, children) + } + + getCursorsClassName () { + return this.props.cursorsBlinkedOff ? 'cursors blink-off' : 'cursors' + } + + renderHiddenInput () { + const { + lineHeight, hiddenInputPosition, didBlurHiddenInput, didFocusHiddenInput, + didTextInput, didKeydown, didKeyup, didKeypress, didCompositionStart, + didCompositionUpdate, didCompositionEnd + } = this.props + + let top, left + if (hiddenInputPosition) { + top = hiddenInputPosition.pixelTop + left = hiddenInputPosition.pixelLeft + } else { + top = 0 + left = 0 + } + + return $.input({ + ref: 'hiddenInput', + key: 'hiddenInput', + className: 'hidden-input', + on: { + blur: didBlurHiddenInput, + focus: didFocusHiddenInput, + textInput: didTextInput, + keydown: didKeydown, + keyup: didKeyup, + keypress: didKeypress, + compositionstart: didCompositionStart, + compositionupdate: didCompositionUpdate, + compositionend: didCompositionEnd + }, + tabIndex: -1, + style: { + position: 'absolute', + width: '1px', + height: lineHeight + 'px', + top: top + 'px', + left: left + 'px', + opacity: 0, + padding: 0, + border: 0 + } + }) + } +} + class LinesTileComponent { constructor (props) { this.props = props From d92e0fc0a10acfaf53e3dd6cd9b88fd35a35acf0 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 24 Apr 2017 06:23:07 -0600 Subject: [PATCH 314/403] Eliminate cached placeholderTextVnode --- src/text-editor-component.js | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index aff7da73bca..79798c7d6d8 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -147,8 +147,6 @@ class TextEditorComponent { this.pendingScrollLeftColumn = this.props.initialScrollLeftColumn this.measuredContent = false - this.placeholderTextVnode = null - this.queryGuttersToRender() this.queryMaxLineNumberDigits() this.observeBlockDecorations() @@ -625,17 +623,14 @@ class TextEditorComponent { } renderPlaceholderText () { - if (!this.measuredContent) { - this.placeholderTextVnode = null - const {model} = this.props - if (model.isEmpty()) { - const placeholderText = model.getPlaceholderText() - if (placeholderText != null) { - this.placeholderTextVnode = $.div({className: 'placeholder-text'}, placeholderText) - } + const {model} = this.props + if (model.isEmpty()) { + const placeholderText = model.getPlaceholderText() + if (placeholderText != null) { + return $.div({className: 'placeholder-text'}, placeholderText) } } - return this.placeholderTextVnode + return null } renderCharacterMeasurementLine () { From 97125ad083e7a1d21396034a37485689f8a1950b Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 24 Apr 2017 14:35:17 +0200 Subject: [PATCH 315/403] Update gutter container only once per frame unless its width changes Signed-off-by: Nathan Sobo --- src/text-editor-component.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 79798c7d6d8..f8ef0af26f5 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -330,9 +330,7 @@ class TextEditorComponent { measureContentDuringUpdateSync () { if (this.remeasureGutterDimensions) { - if (this.measureGutterDimensions()) { - // TODO: Ensure we update the gutter container in the second phase of the update - } + this.measureGutterDimensions() this.remeasureGutterDimensions = false } const wasHorizontalScrollbarVisible = this.isHorizontalScrollbarVisible() @@ -456,6 +454,7 @@ class TextEditorComponent { key: 'gutterContainer', rootComponent: this, hasInitialMeasurements: this.hasInitialMeasurements, + measuredContent: this.measuredContent, scrollTop: this.getScrollTop(), scrollHeight: this.getScrollHeight(), lineNumberGutterWidth: this.getLineNumberGutterWidth(), @@ -2564,8 +2563,17 @@ class GutterContainerComponent { } update (props) { - this.props = props - etch.updateSync(this) + if (this.shouldUpdate(props)) { + this.props = props + etch.updateSync(this) + } + } + + shouldUpdate (props) { + return ( + !props.measuredContent || + props.lineNumberGutterWidth !== this.props.lineNumberGutterWidth + ) } render () { From 1544e3bc7fc3b87f8eab5cb2c288cbd2495f1915 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 24 Apr 2017 14:37:56 +0200 Subject: [PATCH 316/403] Update cursors only once per frame (after content has been measured) Signed-off-by: Nathan Sobo --- src/text-editor-component.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index f8ef0af26f5..2bc0b581bc8 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -612,6 +612,7 @@ class TextEditorComponent { didCompositionStart: this.didCompositionStart, didCompositionUpdate: this.didCompositionUpdate, didCompositionEnd: this.didCompositionEnd, + measuredContent: this.measuredContent, lineHeight: this.getLineHeight(), scrollHeight: this.getScrollHeight(), scrollWidth: this.getScrollWidth(), @@ -2909,8 +2910,10 @@ class CursorsAndInputComponent { } update (props) { - this.props = props - etch.updateSync(this) + if (props.measuredContent) { + this.props = props + etch.updateSync(this) + } } updateCursorBlinkSync (cursorsBlinkedOff) { From 4b34c476a3423c2cc49c9d95d03daf6750995c49 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 24 Apr 2017 14:51:33 +0200 Subject: [PATCH 317/403] Update lines content only once per frame Signed-off-by: Nathan Sobo --- src/text-editor-component.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 2bc0b581bc8..088fdf5d3c9 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -3084,6 +3084,7 @@ class LinesTileComponent { } = this.props return $(LinesComponent, { + measuredContent, height, width, tileStartRow, @@ -3195,7 +3196,7 @@ class LinesComponent { } update (props) { - var {width, height} = props + var {width, height, measuredContent} = props if (this.props.width !== width) { this.element.style.width = width + 'px' @@ -3205,8 +3206,10 @@ class LinesComponent { this.element.style.height = height + 'px' } - this.updateLines(props) - this.updateBlockDecorations(props) + if (!measuredContent) { + this.updateLines(props) + this.updateBlockDecorations(props) + } this.props = props } From 45e95912fafff06c608912326876373aa5991991 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 24 Apr 2017 15:35:33 +0200 Subject: [PATCH 318/403] Cache derived dimensions during each phase of `updateSync` Signed-off-by: Nathan Sobo --- src/text-editor-component.js | 72 +++++++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 088fdf5d3c9..d8784080a9d 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -96,6 +96,7 @@ class TextEditorComponent { horizontalScrollbarHeight: 0, longestLineWidth: 0 } + this.derivedDimensionsCache = {} this.visible = false this.cursorsBlinking = false this.cursorsBlinkedOff = false @@ -247,6 +248,7 @@ class TextEditorComponent { this.updateSyncAfterMeasuringContent() } + this.derivedDimensionsCache = {} if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise() } @@ -316,6 +318,7 @@ class TextEditorComponent { } updateSyncBeforeMeasuringContent () { + this.derivedDimensionsCache = {} if (this.pendingAutoscroll) this.autoscrollVertically() this.populateVisibleRowRange() this.queryScreenLinesToRender() @@ -342,6 +345,7 @@ class TextEditorComponent { this.updateAbsolutePositionedDecorations() if (this.pendingAutoscroll) { + this.derivedDimensionsCache = {} this.autoscrollHorizontally() if (!wasHorizontalScrollbarVisible && this.isHorizontalScrollbarVisible()) { this.autoscrollVertically() @@ -351,6 +355,7 @@ class TextEditorComponent { } updateSyncAfterMeasuringContent () { + this.derivedDimensionsCache = {} etch.updateSync(this) this.currentFrameLineNumberGutterProps = null @@ -2298,46 +2303,70 @@ class TextEditorComponent { return (startRow / this.getRowsPerTile()) % this.getRenderedTileCount() } - getFirstTileStartRow () { - return this.tileStartRowForRow(this.getFirstVisibleRow()) - } - getRenderedStartRow () { - return this.getFirstTileStartRow() + if (this.derivedDimensionsCache.renderedStartRow == null) { + this.derivedDimensionsCache.renderedStartRow = this.tileStartRowForRow(this.getFirstVisibleRow()) + } + + return this.derivedDimensionsCache.renderedStartRow } getRenderedEndRow () { - return Math.min( - this.props.model.getApproximateScreenLineCount(), - this.getFirstTileStartRow() + this.getVisibleTileCount() * this.getRowsPerTile() - ) + if (this.derivedDimensionsCache.renderedEndRow == null) { + this.derivedDimensionsCache.renderedEndRow = Math.min( + this.props.model.getApproximateScreenLineCount(), + this.getRenderedStartRow() + this.getVisibleTileCount() * this.getRowsPerTile() + ) + } + + return this.derivedDimensionsCache.renderedEndRow } getRenderedRowCount () { - return Math.max(0, this.getRenderedEndRow() - this.getRenderedStartRow()) + if (this.derivedDimensionsCache.renderedRowCount == null) { + this.derivedDimensionsCache.renderedRowCount = Math.max(0, this.getRenderedEndRow() - this.getRenderedStartRow()) + } + + return this.derivedDimensionsCache.renderedRowCount } getRenderedTileCount () { - return Math.ceil(this.getRenderedRowCount() / this.getRowsPerTile()) + if (this.derivedDimensionsCache.renderedTileCount == null) { + this.derivedDimensionsCache.renderedTileCount = Math.ceil(this.getRenderedRowCount() / this.getRowsPerTile()) + } + + return this.derivedDimensionsCache.renderedTileCount } getFirstVisibleRow () { - return this.rowForPixelPosition(this.getScrollTop()) + if (this.derivedDimensionsCache.firstVisibleRow == null) { + this.derivedDimensionsCache.firstVisibleRow = this.rowForPixelPosition(this.getScrollTop()) + } + + return this.derivedDimensionsCache.firstVisibleRow } getLastVisibleRow () { - return Math.min( - this.props.model.getApproximateScreenLineCount() - 1, - this.rowForPixelPosition(this.getScrollBottom()) - ) - } + if (this.derivedDimensionsCache.lastVisibleRow == null) { + this.derivedDimensionsCache.lastVisibleRow = Math.min( + this.props.model.getApproximateScreenLineCount() - 1, + this.rowForPixelPosition(this.getScrollBottom()) + ) + } - getFirstVisibleColumn () { - return Math.floor(this.getScrollLeft() / this.getBaseCharacterWidth()) + return this.derivedDimensionsCache.lastVisibleRow } getVisibleTileCount () { - return Math.floor((this.getLastVisibleRow() - this.getFirstVisibleRow()) / this.getRowsPerTile()) + 2 + if (this.derivedDimensionsCache.visibleTileCount == null) { + this.derivedDimensionsCache.visibleTileCount = Math.floor((this.getLastVisibleRow() - this.getFirstVisibleRow()) / this.getRowsPerTile()) + 2 + } + + return this.derivedDimensionsCache.visibleTileCount + } + + getFirstVisibleColumn () { + return Math.floor(this.getScrollLeft() / this.getBaseCharacterWidth()) } getScrollTop () { @@ -2348,6 +2377,7 @@ class TextEditorComponent { setScrollTop (scrollTop) { scrollTop = Math.round(Math.max(0, Math.min(this.getMaxScrollTop(), scrollTop))) if (scrollTop !== this.scrollTop) { + this.derivedDimensionsCache = {} this.scrollTopPending = true this.scrollTop = scrollTop this.element.emitter.emit('did-change-scroll-top', scrollTop) @@ -2442,7 +2472,7 @@ class TextEditorComponent { // Ensure the spatial index is populated with rows that are currently // visible so we *at least* get the longest row in the visible range. populateVisibleRowRange () { - const endRow = this.getFirstTileStartRow() + this.getVisibleTileCount() * this.getRowsPerTile() + const endRow = this.getRenderedStartRow() + this.getVisibleTileCount() * this.getRowsPerTile() this.props.model.displayLayer.populateSpatialIndexIfNeeded(Infinity, endRow) } From 638bb78ecbe3ae9379f362ef30a5f36fd56a556f Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 24 Apr 2017 16:22:26 +0200 Subject: [PATCH 319/403] Fix build failures --- spec/text-editor-element-spec.js | 4 ++-- src/text-editor-element.js | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/spec/text-editor-element-spec.js b/spec/text-editor-element-spec.js index 56601c455d3..60b0fd708e7 100644 --- a/spec/text-editor-element-spec.js +++ b/spec/text-editor-element-spec.js @@ -238,9 +238,9 @@ describe('TextEditorElement', () => { ) describe('::getDefaultCharacterWidth', () => { - it('returns null before the element is attached', () => { + it('returns 0 before the element is attached', () => { const element = buildTextEditorElement({attach: false}) - expect(element.getDefaultCharacterWidth()).toBeNull() + expect(element.getDefaultCharacterWidth()).toBe(0) }) it('returns the width of a character in the root scope', () => { diff --git a/src/text-editor-element.js b/src/text-editor-element.js index bb530cbb966..04e22447f38 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -255,8 +255,6 @@ class TextEditorElement extends HTMLElement { const end = this.pixelPositionForScreenPosition(range.end) const lineHeight = this.getComponent().getLineHeight() - console.log(start, end); - return { top: start.top, left: start.left, From 72351481c79b88ed60f7f6bd7e253723ced92989 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 24 Apr 2017 19:23:49 +0200 Subject: [PATCH 320/403] Fix positioning for block decorations located at the beginning of a tile --- spec/text-editor-component-spec.js | 50 ++++++++++++++---------------- src/text-editor-component.js | 10 +++--- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 2f319863e93..71f5c1539b7 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1609,33 +1609,7 @@ describe('TextEditorComponent', () => { expect(element.contains(item5)).toBe(false) expect(element.contains(item6)).toBe(false) - // scroll past the first tile - await setScrollTop(component, 3 * component.getLineHeight() + getElementHeight(item1) + getElementHeight(item2)) - expect(component.getRenderedStartRow()).toBe(3) - expect(component.getRenderedEndRow()).toBe(9) - expect(component.getScrollHeight()).toBe( - editor.getScreenLineCount() * component.getLineHeight() + - getElementHeight(item1) + getElementHeight(item2) + getElementHeight(item3) + - getElementHeight(item4) + getElementHeight(item5) + getElementHeight(item6) - ) - assertTilesAreSizedAndPositionedCorrectly(component, [ - {tileStartRow: 3, height: 3 * component.getLineHeight() + getElementHeight(item3)}, - {tileStartRow: 6, height: 3 * component.getLineHeight() + getElementHeight(item4) + getElementHeight(item5)} - ]) - assertLinesAreAlignedWithLineNumbers(component) - expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) - expect(element.contains(item1)).toBe(false) - expect(element.contains(item2)).toBe(false) - expect(item3.previousSibling).toBe(lineNodeForScreenRow(component, 3)) - expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 4)) - expect(item4.previousSibling).toBe(lineNodeForScreenRow(component, 6)) - expect(item4.nextSibling).toBe(lineNodeForScreenRow(component, 7)) - expect(item5.previousSibling).toBe(lineNodeForScreenRow(component, 7)) - expect(item5.nextSibling).toBe(lineNodeForScreenRow(component, 8)) - expect(element.contains(item6)).toBe(false) - // destroy decoration1 - await setScrollTop(component, 0) decoration1.destroy() await component.getNextUpdatePromise() expect(component.getRenderedStartRow()).toBe(0) @@ -1711,6 +1685,30 @@ describe('TextEditorComponent', () => { expect(element.contains(item5)).toBe(false) expect(element.contains(item6)).toBe(false) + // scroll past the first tile + await setScrollTop(component, 3 * component.getLineHeight() + getElementHeight(item3)) + expect(component.getRenderedStartRow()).toBe(3) + expect(component.getRenderedEndRow()).toBe(9) + expect(component.getScrollHeight()).toBe( + editor.getScreenLineCount() * component.getLineHeight() + + getElementHeight(item2) + getElementHeight(item3) + + getElementHeight(item4) + getElementHeight(item5) + getElementHeight(item6) + ) + assertTilesAreSizedAndPositionedCorrectly(component, [ + {tileStartRow: 3, height: 3 * component.getLineHeight() + getElementHeight(item2)}, + {tileStartRow: 6, height: 3 * component.getLineHeight()} + ]) + assertLinesAreAlignedWithLineNumbers(component) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) + expect(element.contains(item1)).toBe(false) + expect(item2.previousSibling).toBeNull() + expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 3)) + expect(element.contains(item3)).toBe(false) + expect(element.contains(item4)).toBe(false) + expect(element.contains(item5)).toBe(false) + expect(element.contains(item6)).toBe(false) + await setScrollTop(component, 0) + // undo the previous change editor.undo() await component.getNextUpdatePromise() diff --git a/src/text-editor-component.js b/src/text-editor-component.js index d8784080a9d..66f372ea154 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2742,12 +2742,10 @@ class LineNumberGutterComponent { style: {width: width + 'px'}, dataset: {bufferRow} } - if (row === 0 || i > 0) { - let currentRowTop = rootComponent.pixelPositionAfterBlocksForRow(row) - let previousRowBottom = rootComponent.pixelPositionAfterBlocksForRow(row - 1) + lineHeight - if (currentRowTop > previousRowBottom) { - lineNumberProps.style.marginTop = (currentRowTop - previousRowBottom) + 'px' - } + const currentRowTop = rootComponent.pixelPositionAfterBlocksForRow(row) + const previousRowBottom = rootComponent.pixelPositionAfterBlocksForRow(row - 1) + lineHeight + if (currentRowTop > previousRowBottom) { + lineNumberProps.style.marginTop = (currentRowTop - previousRowBottom) + 'px' } tileChildren[row - tileStartRow] = $.div(lineNumberProps, From 46daf64e12297f5dd42c7e18577a1408cde0d544 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 24 Apr 2017 19:36:19 +0200 Subject: [PATCH 321/403] Set autoHeight: true explicitly in benchmarks --- benchmarks/text-editor-large-file-construction.bench.js | 2 +- benchmarks/text-editor-long-lines.bench.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/benchmarks/text-editor-large-file-construction.bench.js b/benchmarks/text-editor-large-file-construction.bench.js index 30b640733f5..ec037e9e4fe 100644 --- a/benchmarks/text-editor-large-file-construction.bench.js +++ b/benchmarks/text-editor-large-file-construction.bench.js @@ -26,7 +26,7 @@ export default async function ({test}) { let t0 = window.performance.now() const buffer = new TextBuffer({text}) - const editor = new TextEditor({buffer, largeFileMode: true}) + const editor = new TextEditor({buffer, autoHeight: false, largeFileMode: true}) atom.workspace.getActivePane().activateItem(editor) let t1 = window.performance.now() diff --git a/benchmarks/text-editor-long-lines.bench.js b/benchmarks/text-editor-long-lines.bench.js index c162db42037..ac90e4a7164 100644 --- a/benchmarks/text-editor-long-lines.bench.js +++ b/benchmarks/text-editor-long-lines.bench.js @@ -33,7 +33,7 @@ export default async function ({test}) { let t0 = window.performance.now() const buffer = new TextBuffer({text}) - const editor = new TextEditor({buffer, largeFileMode: true}) + const editor = new TextEditor({buffer, autoHeight: false, largeFileMode: true}) editor.setGrammar(atom.grammars.grammarForScopeName('source.js')) atom.workspace.getActivePane().activateItem(editor) let t1 = window.performance.now() From bd6eedcc88b11878fbbbd773a82edb8c32c219e0 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 24 Apr 2017 15:22:24 -0600 Subject: [PATCH 322/403] Eliminate strictly contained divs wrapping lines and highlights I was hoping to strictly contain the layouts of highlights an lines separately, since they are updated during different render phases. Unfortunately, strict containment requires both divs to be positioned absolutely. This in turn creates separate stacking contexts for lines and highlights, which makes it impossible to render highlights in front lines which themes sometimes need to do. For example, atom-material-syntax pushes bracket matcher highlights to the front so they are not obscured by the theme's solid black cursor line background. /cc @as-cii. You should examine my work here and make sure I'm not screwing something up with your line/block decoration update code. --- spec/text-editor-component-spec.js | 20 +-- src/text-editor-component.js | 235 ++++++++++++----------------- 2 files changed, 103 insertions(+), 152 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 71f5c1539b7..4cff55db358 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1575,7 +1575,7 @@ describe('TextEditorComponent', () => { ]) assertLinesAreAlignedWithLineNumbers(component) expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) - expect(item1.previousSibling).toBeNull() + expect(item1.previousSibling.className).toBe('highlights') expect(item1.nextSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 1)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 2)) @@ -1599,7 +1599,7 @@ describe('TextEditorComponent', () => { ]) assertLinesAreAlignedWithLineNumbers(component) expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) - expect(item1.previousSibling).toBeNull() + expect(item1.previousSibling.className).toBe('highlights') expect(item1.nextSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 1)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 2)) @@ -1654,7 +1654,7 @@ describe('TextEditorComponent', () => { expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) - expect(item3.previousSibling).toBeNull() + expect(item3.previousSibling.className).toBe('highlights') expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0)) expect(element.contains(item4)).toBe(false) expect(element.contains(item5)).toBe(false) @@ -1677,9 +1677,9 @@ describe('TextEditorComponent', () => { assertLinesAreAlignedWithLineNumbers(component) expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) expect(element.contains(item1)).toBe(false) - expect(item2.previousSibling).toBeNull() + expect(item2.previousSibling.className).toBe('highlights') expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 3)) - expect(item3.previousSibling).toBeNull() + expect(item3.previousSibling.className).toBe('highlights') expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0)) expect(element.contains(item4)).toBe(false) expect(element.contains(item5)).toBe(false) @@ -1701,7 +1701,7 @@ describe('TextEditorComponent', () => { assertLinesAreAlignedWithLineNumbers(component) expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) expect(element.contains(item1)).toBe(false) - expect(item2.previousSibling).toBeNull() + expect(item2.previousSibling.className).toBe('highlights') expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 3)) expect(element.contains(item3)).toBe(false) expect(element.contains(item4)).toBe(false) @@ -1728,7 +1728,7 @@ describe('TextEditorComponent', () => { expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) - expect(item3.previousSibling).toBeNull() + expect(item3.previousSibling.className).toBe('highlights') expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0)) expect(element.contains(item4)).toBe(false) expect(element.contains(item5)).toBe(false) @@ -1760,7 +1760,7 @@ describe('TextEditorComponent', () => { expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) - expect(item3.previousSibling).toBeNull() + expect(item3.previousSibling.className).toBe('highlights') expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0)) expect(element.contains(item4)).toBe(false) expect(element.contains(item5)).toBe(false) @@ -1799,7 +1799,7 @@ describe('TextEditorComponent', () => { expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) - expect(item3.previousSibling).toBeNull() + expect(item3.previousSibling.className).toBe('highlights') expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0)) expect(element.contains(item4)).toBe(false) expect(element.contains(item5)).toBe(false) @@ -1826,7 +1826,7 @@ describe('TextEditorComponent', () => { expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) - expect(item3.previousSibling).toBeNull() + expect(item3.previousSibling.className).toBe('highlights') expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item4.previousSibling).toBe(lineNodeForScreenRow(component, 6)) expect(item4.nextSibling).toBe(lineNodeForScreenRow(component, 7)) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 66f372ea154..2ee3a882a2b 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -3045,12 +3045,25 @@ class LinesTileComponent { constructor (props) { this.props = props etch.initialize(this) + this.createLines() + this.updateBlockDecorations({}, props) } update (newProps) { if (this.shouldUpdate(newProps)) { + const oldProps = this.props this.props = newProps etch.updateSync(this) + if (!newProps.measuredContent) { + this.updateLines(oldProps, newProps) + this.updateBlockDecorations(oldProps, newProps) + } + } + } + + destroy () { + for (let i = 0; i < this.lineComponents.length; i++) { + this.lineComponents[i].destroy() } } @@ -3069,8 +3082,8 @@ class LinesTileComponent { backgroundColor: 'inherit' } }, - this.renderHighlights(), - this.renderLines() + this.renderHighlights() + // Lines and block decorations will be manually inserted here for efficiency ) } @@ -3094,111 +3107,17 @@ class LinesTileComponent { return $.div( { className: 'highlights', - style: { - position: 'absolute', - contain: 'strict', - height: height + 'px', - width: width + 'px' - } - }, children + style: {contain: 'layout'} + }, + children ) } - renderLines () { - const { - measuredContent, height, width, - tileStartRow, screenLines, lineDecorations, blockDecorations, displayLayer, - lineNodesByScreenLineId, textNodesByScreenLineId - } = this.props - - return $(LinesComponent, { - measuredContent, - height, - width, - tileStartRow, - screenLines, - lineDecorations, - blockDecorations, - displayLayer, - lineNodesByScreenLineId, - textNodesByScreenLineId - }) - } - - shouldUpdate (newProps) { - const oldProps = this.props - if (oldProps.top !== newProps.top) return true - if (oldProps.height !== newProps.height) return true - if (oldProps.width !== newProps.width) return true - if (oldProps.lineHeight !== newProps.lineHeight) return true - if (oldProps.tileStartRow !== newProps.tileStartRow) return true - if (oldProps.tileEndRow !== newProps.tileEndRow) return true - if (!arraysEqual(oldProps.screenLines, newProps.screenLines)) return true - if (!arraysEqual(oldProps.lineDecorations, newProps.lineDecorations)) return true - - if (!oldProps.highlightDecorations && newProps.highlightDecorations) return true - if (oldProps.highlightDecorations && !newProps.highlightDecorations) return true - - if (oldProps.highlightDecorations && newProps.highlightDecorations) { - if (oldProps.highlightDecorations.length !== newProps.highlightDecorations.length) return true - - for (let i = 0, length = oldProps.highlightDecorations.length; i < length; i++) { - const oldHighlight = oldProps.highlightDecorations[i] - const newHighlight = newProps.highlightDecorations[i] - if (oldHighlight.className !== newHighlight.className) return true - if (newHighlight.flashRequested) return true - if (oldHighlight.startPixelTop !== newHighlight.startPixelTop) return true - if (oldHighlight.startPixelLeft !== newHighlight.startPixelLeft) return true - if (oldHighlight.endPixelTop !== newHighlight.endPixelTop) return true - if (oldHighlight.endPixelLeft !== newHighlight.endPixelLeft) return true - if (!oldHighlight.screenRange.isEqual(newHighlight.screenRange)) return true - } - } - - if (oldProps.blockDecorations && newProps.blockDecorations) { - if (oldProps.blockDecorations.size !== newProps.blockDecorations.size) return true - - let blockDecorationsChanged = false - - oldProps.blockDecorations.forEach((oldDecorations, screenLineId) => { - if (!blockDecorationsChanged) { - const newDecorations = newProps.blockDecorations.get(screenLineId) - blockDecorationsChanged = (newDecorations == null || !arraysEqual(oldDecorations, newDecorations)) - } - }) - if (blockDecorationsChanged) return true - - newProps.blockDecorations.forEach((newDecorations, screenLineId) => { - if (!blockDecorationsChanged) { - const oldDecorations = oldProps.blockDecorations.get(screenLineId) - blockDecorationsChanged = (oldDecorations == null) - } - }) - if (blockDecorationsChanged) return true - } else if (oldProps.blockDecorations) { - return true - } else if (newProps.blockDecorations) { - return true - } - - return false - } -} - -class LinesComponent { - constructor (props) { - this.props = {} + createLines () { const { - width, height, tileStartRow, - screenLines, lineDecorations, + element, tileStartRow, screenLines, lineDecorations, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId - } = props - - this.element = document.createElement('div') - this.element.style.position = 'absolute' - this.element.style.contain = 'strict' - this.element.style.height = height + 'px' - this.element.style.width = width + 'px' + } = this.props this.lineComponents = [] for (let i = 0, length = screenLines.length; i < length; i++) { @@ -3213,42 +3132,15 @@ class LinesComponent { this.element.appendChild(component.element) this.lineComponents.push(component) } - this.updateBlockDecorations(props) - this.props = props - } - - destroy () { - for (let i = 0; i < this.lineComponents.length; i++) { - this.lineComponents[i].destroy() - } - } - - update (props) { - var {width, height, measuredContent} = props - - if (this.props.width !== width) { - this.element.style.width = width + 'px' - } - - if (this.props.height !== height) { - this.element.style.height = height + 'px' - } - - if (!measuredContent) { - this.updateLines(props) - this.updateBlockDecorations(props) - } - - this.props = props } - updateLines (props) { + updateLines (oldProps, newProps) { var { screenLines, tileStartRow, lineDecorations, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId - } = props + } = newProps - var oldScreenLines = this.props.screenLines + var oldScreenLines = oldProps.screenLines var newScreenLines = screenLines var oldScreenLinesEndIndex = oldScreenLines.length var newScreenLinesEndIndex = newScreenLines.length @@ -3303,7 +3195,7 @@ class LinesComponent { lineNodesByScreenLineId, textNodesByScreenLineId }) - this.element.insertBefore(newScreenLineComponent.element, this.getFirstElementForScreenLine(oldScreenLine)) + this.element.insertBefore(newScreenLineComponent.element, this.getFirstElementForScreenLine(oldProps, oldScreenLine)) newScreenLineComponents.push(newScreenLineComponent) newScreenLineIndex++ @@ -3345,8 +3237,8 @@ class LinesComponent { } } - getFirstElementForScreenLine (screenLine) { - var blockDecorations = this.props.blockDecorations ? this.props.blockDecorations.get(screenLine.id) : null + getFirstElementForScreenLine (oldProps, screenLine) { + var blockDecorations = oldProps.blockDecorations ? oldProps.blockDecorations.get(screenLine.id) : null if (blockDecorations) { var blockDecorationElementsBeforeOldScreenLine = [] for (let i = 0; i < blockDecorations.length; i++) { @@ -3366,15 +3258,15 @@ class LinesComponent { } } - return this.props.lineNodesByScreenLineId.get(screenLine.id) + return oldProps.lineNodesByScreenLineId.get(screenLine.id) } - updateBlockDecorations (props) { - var {blockDecorations, lineNodesByScreenLineId} = props + updateBlockDecorations (oldProps, newProps) { + var {blockDecorations, lineNodesByScreenLineId} = newProps - if (this.props.blockDecorations) { - this.props.blockDecorations.forEach((oldDecorations, screenLineId) => { - var newDecorations = props.blockDecorations ? props.blockDecorations.get(screenLineId) : null + if (oldProps.blockDecorations) { + oldProps.blockDecorations.forEach((oldDecorations, screenLineId) => { + var newDecorations = newProps.blockDecorations ? newProps.blockDecorations.get(screenLineId) : null for (var i = 0; i < oldDecorations.length; i++) { var oldDecoration = oldDecorations[i] if (newDecorations && newDecorations.includes(oldDecoration)) continue @@ -3389,7 +3281,7 @@ class LinesComponent { if (blockDecorations) { blockDecorations.forEach((newDecorations, screenLineId) => { - var oldDecorations = this.props.blockDecorations ? this.props.blockDecorations.get(screenLineId) : null + var oldDecorations = oldProps.blockDecorations ? oldProps.blockDecorations.get(screenLineId) : null for (var i = 0; i < newDecorations.length; i++) { var newDecoration = newDecorations[i] if (oldDecorations && oldDecorations.includes(newDecoration)) continue @@ -3405,6 +3297,65 @@ class LinesComponent { }) } } + + shouldUpdate (newProps) { + const oldProps = this.props + if (oldProps.top !== newProps.top) return true + if (oldProps.height !== newProps.height) return true + if (oldProps.width !== newProps.width) return true + if (oldProps.lineHeight !== newProps.lineHeight) return true + if (oldProps.tileStartRow !== newProps.tileStartRow) return true + if (oldProps.tileEndRow !== newProps.tileEndRow) return true + if (!arraysEqual(oldProps.screenLines, newProps.screenLines)) return true + if (!arraysEqual(oldProps.lineDecorations, newProps.lineDecorations)) return true + + if (!oldProps.highlightDecorations && newProps.highlightDecorations) return true + if (oldProps.highlightDecorations && !newProps.highlightDecorations) return true + + if (oldProps.highlightDecorations && newProps.highlightDecorations) { + if (oldProps.highlightDecorations.length !== newProps.highlightDecorations.length) return true + + for (let i = 0, length = oldProps.highlightDecorations.length; i < length; i++) { + const oldHighlight = oldProps.highlightDecorations[i] + const newHighlight = newProps.highlightDecorations[i] + if (oldHighlight.className !== newHighlight.className) return true + if (newHighlight.flashRequested) return true + if (oldHighlight.startPixelTop !== newHighlight.startPixelTop) return true + if (oldHighlight.startPixelLeft !== newHighlight.startPixelLeft) return true + if (oldHighlight.endPixelTop !== newHighlight.endPixelTop) return true + if (oldHighlight.endPixelLeft !== newHighlight.endPixelLeft) return true + if (!oldHighlight.screenRange.isEqual(newHighlight.screenRange)) return true + } + } + + if (oldProps.blockDecorations && newProps.blockDecorations) { + if (oldProps.blockDecorations.size !== newProps.blockDecorations.size) return true + + let blockDecorationsChanged = false + + oldProps.blockDecorations.forEach((oldDecorations, screenLineId) => { + if (!blockDecorationsChanged) { + const newDecorations = newProps.blockDecorations.get(screenLineId) + blockDecorationsChanged = (newDecorations == null || !arraysEqual(oldDecorations, newDecorations)) + } + }) + if (blockDecorationsChanged) return true + + newProps.blockDecorations.forEach((newDecorations, screenLineId) => { + if (!blockDecorationsChanged) { + const oldDecorations = oldProps.blockDecorations.get(screenLineId) + blockDecorationsChanged = (oldDecorations == null) + } + }) + if (blockDecorationsChanged) return true + } else if (oldProps.blockDecorations) { + return true + } else if (newProps.blockDecorations) { + return true + } + + return false + } } class LineComponent { From c7228f6d81f473e5a8ee8033d0df39eb8aeb5230 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 24 Apr 2017 16:30:15 -0600 Subject: [PATCH 323/403] Fix error when attaching soft-wrap editor in synchronous update mode Taking the initial measurement was setting the soft wrap column, which was triggering a display layer reset, which was scheduling an update. This update occurred at an unexpected time causing an exception. --- spec/text-editor-component-spec.js | 10 +++++++++- src/text-editor-component.js | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 4cff55db358..df8dbae9a53 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -2915,6 +2915,14 @@ describe('TextEditorComponent', () => { ]) }) + it('does not throw an exception on attachment when setting the soft-wrap column', () => { + const {component, element, editor} = buildComponent({width: 435, attach: false, updatedSynchronously: true}) + editor.setSoftWrapped(true) + spyOn(window, 'onerror').andCallThrough() + jasmine.attachToDOM(element) // should not throw an exception + expect(window.onerror).not.toHaveBeenCalled() + }) + it('updates synchronously when creating a component via TextEditor and TextEditorElement.prototype.updatedSynchronously is true', () => { TextEditorElement.prototype.setUpdatedSynchronously(true) const editor = buildEditor() @@ -3102,7 +3110,7 @@ function buildComponent (params = {}) { const component = new TextEditorComponent({ model: editor, rowsPerTile: params.rowsPerTile, - updatedSynchronously: false, + updatedSynchronously: params.updatedSynchronously || false, platform: params.platform, mouseWheelScrollSensitivity: params.mouseWheelScrollSensitivity }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 2ee3a882a2b..bb28b917d94 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1182,8 +1182,8 @@ class TextEditorComponent { didShow () { if (!this.visible && this.isVisible()) { - this.visible = true if (!this.hasInitialMeasurements) this.measureDimensions() + this.visible = true this.props.model.setVisible(true) this.updateSync() this.flushPendingLogicalScrollPosition() From c36303e631419a69e8c6fdab0585b598b789a79a Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 24 Apr 2017 16:59:36 -0600 Subject: [PATCH 324/403] Avoid blowing away classes assigned on the editor element by packages /cc @t9md --- spec/text-editor-component-spec.js | 13 +++++++++ src/text-editor-component.js | 47 +++++++++++++++++++++++------- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index df8dbae9a53..fd2af0ce2d4 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -545,6 +545,19 @@ describe('TextEditorComponent', () => { '7', '8', '8', '8', '9', '10', '11', '11', '12' ]) }) + + it('does not blow away class names added to the element by packages when changing the class name', async () => { + assertDocumentFocused() + const {component, element, editor} = buildComponent() + element.classList.add('a', 'b') + expect(element.className).toBe('editor a b') + element.focus() + await component.getNextUpdatePromise() + expect(element.className).toBe('editor a b is-focused') + document.body.focus() + await component.getNextUpdatePromise() + expect(element.className).toBe('editor a b') + }) }) describe('mini editors', () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index bb28b917d94..0c9ea9837f4 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -327,6 +327,7 @@ class TextEditorComponent { this.queryDecorationsToRender() this.shouldRenderDummyScrollbars = !this.remeasureScrollbars etch.updateSync(this) + this.updateClassList() this.shouldRenderDummyScrollbars = true this.didMeasureVisibleBlockDecoration = false } @@ -403,17 +404,8 @@ class TextEditorComponent { } let attributes = null - let className = this.focused ? 'editor is-focused' : 'editor' if (model.isMini()) { attributes = {mini: ''} - className = className + ' mini' - } - - for (var i = 0; i < model.selections.length; i++) { - if (!model.selections[i].isEmpty()) { - className += ' has-selection' - break - } } const dataset = {encoding: model.getEncoding()} @@ -424,7 +416,7 @@ class TextEditorComponent { return $('atom-text-editor', { - className, + // See this.updateClassList() for construction of the class name style, attributes, dataset, @@ -749,6 +741,41 @@ class TextEditorComponent { ) } + // Imperatively manipulate the class list of the root element to avoid + // clearing classes assigned by package authors. + updateClassList () { + const {model} = this.props + + const oldClassList = this.classList + const newClassList = ['editor'] + if (this.focused) newClassList.push('is-focused') + if (model.isMini()) newClassList.push('mini') + for (var i = 0; i < model.selections.length; i++) { + if (!model.selections[i].isEmpty()) { + newClassList.push('has-selection') + break + } + } + + if (oldClassList) { + for (let i = 0; i < oldClassList.length; i++) { + const className = oldClassList[i] + if (!newClassList.includes(className)) { + this.element.classList.remove(className) + } + } + } + + for (let i = 0; i < newClassList.length; i++) { + const className = newClassList[i] + if (!oldClassList || !oldClassList.includes(className)) { + this.element.classList.add(className) + } + } + + this.classList = newClassList + } + queryScreenLinesToRender () { const {model} = this.props From 16b2fba851e851b497c9f6e7d10b61faa3c68a6f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 24 Apr 2017 17:03:29 -0600 Subject: [PATCH 325/403] Fix lint errors --- src/text-editor-component.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 0c9ea9837f4..2e8f8839bce 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -3115,7 +3115,7 @@ class LinesTileComponent { } renderHighlights () { - const {top, height, width, lineHeight, highlightDecorations} = this.props + const {top, lineHeight, highlightDecorations} = this.props let children = null if (highlightDecorations) { @@ -3142,7 +3142,7 @@ class LinesTileComponent { createLines () { const { - element, tileStartRow, screenLines, lineDecorations, + tileStartRow, screenLines, lineDecorations, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId } = this.props From 570cfdeaff73bba033d2fecfb3d29ca8f4477ce7 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 25 Apr 2017 14:26:17 +0200 Subject: [PATCH 326/403] Ignore resize events if they are delivered while the editor is hidden --- spec/text-editor-component-spec.js | 30 ++++++++++++++++++++++++++++++ src/text-editor-component.js | 26 ++++++++++++++++---------- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index fd2af0ce2d4..258be57aab2 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -558,6 +558,36 @@ describe('TextEditorComponent', () => { await component.getNextUpdatePromise() expect(element.className).toBe('editor a b') }) + + it('ignores resize events when the editor is hidden', async () => { + const {component, element, editor} = buildComponent({autoHeight: false}) + element.style.height = 5 * component.getLineHeight() + 'px' + await component.getNextUpdatePromise() + const originalClientContainerHeight = component.getClientContainerHeight() + const originalGutterContainerWidth = component.getGutterContainerWidth() + const originalLineNumberGutterWidth = component.getLineNumberGutterWidth() + expect(originalClientContainerHeight).toBeGreaterThan(0) + expect(originalGutterContainerWidth).toBeGreaterThan(0) + expect(originalLineNumberGutterWidth).toBeGreaterThan(0) + + element.style.display = 'none' + // In production, resize events are triggered before the intersection + // observer detects the editor's visibility has changed. In tests, we are + // unable to reproduce this scenario and so we simulate them. + expect(component.visible).toBe(true) + component.didResize() + component.didResizeGutterContainer() + expect(component.getClientContainerHeight()).toBe(originalClientContainerHeight) + expect(component.getGutterContainerWidth()).toBe(originalGutterContainerWidth) + expect(component.getLineNumberGutterWidth()).toBe(originalLineNumberGutterWidth) + + // Ensure measurements stay the same after receiving the intersection + // observer events. + await conditionPromise(() => !component.visible) + expect(component.getClientContainerHeight()).toBe(originalClientContainerHeight) + expect(component.getGutterContainerWidth()).toBe(originalGutterContainerWidth) + expect(component.getLineNumberGutterWidth()).toBe(originalLineNumberGutterWidth) + }) }) describe('mini editors', () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 2e8f8839bce..56d008fa26a 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1311,21 +1311,27 @@ class TextEditorComponent { } didResize () { - const clientContainerWidthChanged = this.measureClientContainerWidth() - const clientContainerHeightChanged = this.measureClientContainerHeight() - if (clientContainerWidthChanged || clientContainerHeightChanged) { - if (clientContainerWidthChanged) { - this.remeasureAllBlockDecorations = true - } + // Prevent the component from measuring the client container dimensions when + // getting spurious resize events. + if (this.isVisible()) { + const clientContainerWidthChanged = this.measureClientContainerWidth() + const clientContainerHeightChanged = this.measureClientContainerHeight() + if (clientContainerWidthChanged || clientContainerHeightChanged) { + if (clientContainerWidthChanged) { + this.remeasureAllBlockDecorations = true + } - this.resizeObserver.disconnect() - this.scheduleUpdate() - process.nextTick(() => { this.resizeObserver.observe(this.element) }) + this.resizeObserver.disconnect() + this.scheduleUpdate() + process.nextTick(() => { this.resizeObserver.observe(this.element) }) + } } } didResizeGutterContainer () { - if (this.measureGutterDimensions()) { + // Prevent the component from measuring the gutter dimensions when getting + // spurious resize events. + if (this.isVisible() && this.measureGutterDimensions()) { this.gutterContainerResizeObserver.disconnect() this.scheduleUpdate() process.nextTick(() => { this.gutterContainerResizeObserver.observe(this.refs.gutterContainer.element) }) From f17baf4790189c397d940534f9b11a3653f5dbc1 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 1 May 2017 15:24:32 +0200 Subject: [PATCH 327/403] Use scope ids instead of tags --- spec/tokenized-buffer-iterator-spec.js | 108 ++++++++------------- spec/tokenized-buffer-spec.coffee | 61 +++++++----- src/first-mate-helpers.js | 11 +++ src/text-editor-component.js | 29 ++---- src/text-editor.coffee | 2 +- src/tokenized-buffer-iterator.js | 125 +++++++++++-------------- src/tokenized-buffer.coffee | 16 ++++ 7 files changed, 170 insertions(+), 182 deletions(-) create mode 100644 src/first-mate-helpers.js diff --git a/spec/tokenized-buffer-iterator-spec.js b/spec/tokenized-buffer-iterator-spec.js index cc703bbec5f..e1440c675fa 100644 --- a/spec/tokenized-buffer-iterator-spec.js +++ b/spec/tokenized-buffer-iterator-spec.js @@ -17,16 +17,6 @@ describe('TokenizedBufferIterator', () => { } else { return null } - }, - - grammar: { - scopeForId (id) { - return { - '-1': 'foo', '-2': 'foo', - '-3': 'bar', '-4': 'bar', - '-5': 'baz', '-6': 'baz' - }[id] - } } } @@ -34,57 +24,57 @@ describe('TokenizedBufferIterator', () => { expect(iterator.seek(Point(0, 0))).toEqual([]) expect(iterator.getPosition()).toEqual(Point(0, 0)) - expect(iterator.getCloseTags()).toEqual([]) - expect(iterator.getOpenTags()).toEqual(['syntax--foo']) + expect(iterator.getCloseScopeIds()).toEqual([]) + expect(iterator.getOpenScopeIds()).toEqual([1]) iterator.moveToSuccessor() - expect(iterator.getCloseTags()).toEqual(['syntax--foo']) - expect(iterator.getOpenTags()).toEqual(['syntax--bar']) + expect(iterator.getCloseScopeIds()).toEqual([1]) + expect(iterator.getOpenScopeIds()).toEqual([3]) - expect(iterator.seek(Point(0, 1))).toEqual(['syntax--baz']) + expect(iterator.seek(Point(0, 1))).toEqual([5]) expect(iterator.getPosition()).toEqual(Point(0, 3)) - expect(iterator.getCloseTags()).toEqual([]) - expect(iterator.getOpenTags()).toEqual(['syntax--bar']) + expect(iterator.getCloseScopeIds()).toEqual([]) + expect(iterator.getOpenScopeIds()).toEqual([3]) iterator.moveToSuccessor() expect(iterator.getPosition()).toEqual(Point(0, 3)) - expect(iterator.getCloseTags()).toEqual(['syntax--bar', 'syntax--baz']) - expect(iterator.getOpenTags()).toEqual(['syntax--baz']) + expect(iterator.getCloseScopeIds()).toEqual([3, 5]) + expect(iterator.getOpenScopeIds()).toEqual([5]) - expect(iterator.seek(Point(0, 3))).toEqual(['syntax--baz']) + expect(iterator.seek(Point(0, 3))).toEqual([5]) expect(iterator.getPosition()).toEqual(Point(0, 3)) - expect(iterator.getCloseTags()).toEqual([]) - expect(iterator.getOpenTags()).toEqual(['syntax--bar']) + expect(iterator.getCloseScopeIds()).toEqual([]) + expect(iterator.getOpenScopeIds()).toEqual([3]) iterator.moveToSuccessor() expect(iterator.getPosition()).toEqual(Point(0, 3)) - expect(iterator.getCloseTags()).toEqual(['syntax--bar', 'syntax--baz']) - expect(iterator.getOpenTags()).toEqual(['syntax--baz']) + expect(iterator.getCloseScopeIds()).toEqual([3, 5]) + expect(iterator.getOpenScopeIds()).toEqual([5]) iterator.moveToSuccessor() expect(iterator.getPosition()).toEqual(Point(0, 7)) - expect(iterator.getCloseTags()).toEqual(['syntax--baz']) - expect(iterator.getOpenTags()).toEqual(['syntax--bar']) + expect(iterator.getCloseScopeIds()).toEqual([5]) + expect(iterator.getOpenScopeIds()).toEqual([3]) iterator.moveToSuccessor() expect(iterator.getPosition()).toEqual(Point(0, 7)) - expect(iterator.getCloseTags()).toEqual(['syntax--bar']) - expect(iterator.getOpenTags()).toEqual([]) + expect(iterator.getCloseScopeIds()).toEqual([3]) + expect(iterator.getOpenScopeIds()).toEqual([]) iterator.moveToSuccessor() expect(iterator.getPosition()).toEqual(Point(1, 0)) - expect(iterator.getCloseTags()).toEqual([]) - expect(iterator.getOpenTags()).toEqual([]) + expect(iterator.getCloseScopeIds()).toEqual([]) + expect(iterator.getOpenScopeIds()).toEqual([]) - expect(iterator.seek(Point(0, 5))).toEqual(['syntax--baz']) + expect(iterator.seek(Point(0, 5))).toEqual([5]) expect(iterator.getPosition()).toEqual(Point(0, 7)) - expect(iterator.getCloseTags()).toEqual(['syntax--baz']) - expect(iterator.getOpenTags()).toEqual(['syntax--bar']) + expect(iterator.getCloseScopeIds()).toEqual([5]) + expect(iterator.getOpenScopeIds()).toEqual([3]) iterator.moveToSuccessor() expect(iterator.getPosition()).toEqual(Point(0, 7)) - expect(iterator.getCloseTags()).toEqual(['syntax--bar']) - expect(iterator.getOpenTags()).toEqual([]) + expect(iterator.getCloseScopeIds()).toEqual([3]) + expect(iterator.getOpenScopeIds()).toEqual([]) }) }) @@ -97,12 +87,6 @@ describe('TokenizedBufferIterator', () => { text: '', openScopes: [] } - }, - - grammar: { - scopeForId () { - return 'foo' - } } } @@ -110,17 +94,17 @@ describe('TokenizedBufferIterator', () => { iterator.seek(Point(0, 0)) expect(iterator.getPosition()).toEqual(Point(0, 0)) - expect(iterator.getCloseTags()).toEqual([]) - expect(iterator.getOpenTags()).toEqual(['syntax--foo']) + expect(iterator.getCloseScopeIds()).toEqual([]) + expect(iterator.getOpenScopeIds()).toEqual([1]) iterator.moveToSuccessor() expect(iterator.getPosition()).toEqual(Point(0, 0)) - expect(iterator.getCloseTags()).toEqual(['syntax--foo']) - expect(iterator.getOpenTags()).toEqual(['syntax--foo']) + expect(iterator.getCloseScopeIds()).toEqual([1]) + expect(iterator.getOpenScopeIds()).toEqual([1]) iterator.moveToSuccessor() - expect(iterator.getCloseTags()).toEqual(['syntax--foo']) - expect(iterator.getOpenTags()).toEqual([]) + expect(iterator.getCloseScopeIds()).toEqual([1]) + expect(iterator.getOpenScopeIds()).toEqual([]) }) it("reports a boundary at line end if the next line's open scopes don't match the containing tags for the current line", () => { @@ -145,16 +129,6 @@ describe('TokenizedBufferIterator', () => { openScopes: [-1] } } - }, - - grammar: { - scopeForId (id) { - if (id === -2 || id === -1) { - return 'foo' - } else if (id === -3) { - return 'qux' - } - } } } @@ -162,28 +136,28 @@ describe('TokenizedBufferIterator', () => { iterator.seek(Point(0, 0)) expect(iterator.getPosition()).toEqual(Point(0, 0)) - expect(iterator.getCloseTags()).toEqual([]) - expect(iterator.getOpenTags()).toEqual(['syntax--foo']) + expect(iterator.getCloseScopeIds()).toEqual([]) + expect(iterator.getOpenScopeIds()).toEqual([1]) iterator.moveToSuccessor() expect(iterator.getPosition()).toEqual(Point(0, 3)) - expect(iterator.getCloseTags()).toEqual(['syntax--foo']) - expect(iterator.getOpenTags()).toEqual(['syntax--qux']) + expect(iterator.getCloseScopeIds()).toEqual([1]) + expect(iterator.getOpenScopeIds()).toEqual([3]) iterator.moveToSuccessor() expect(iterator.getPosition()).toEqual(Point(0, 3)) - expect(iterator.getCloseTags()).toEqual(['syntax--qux']) - expect(iterator.getOpenTags()).toEqual([]) + expect(iterator.getCloseScopeIds()).toEqual([3]) + expect(iterator.getOpenScopeIds()).toEqual([]) iterator.moveToSuccessor() expect(iterator.getPosition()).toEqual(Point(1, 0)) - expect(iterator.getCloseTags()).toEqual([]) - expect(iterator.getOpenTags()).toEqual(['syntax--foo']) + expect(iterator.getCloseScopeIds()).toEqual([]) + expect(iterator.getOpenScopeIds()).toEqual([1]) iterator.moveToSuccessor() expect(iterator.getPosition()).toEqual(Point(2, 0)) - expect(iterator.getCloseTags()).toEqual(['syntax--foo']) - expect(iterator.getOpenTags()).toEqual([]) + expect(iterator.getCloseScopeIds()).toEqual([1]) + expect(iterator.getOpenScopeIds()).toEqual([]) }) }) }) diff --git a/spec/tokenized-buffer-spec.coffee b/spec/tokenized-buffer-spec.coffee index eff79ec95a4..5b1863b35d7 100644 --- a/spec/tokenized-buffer-spec.coffee +++ b/spec/tokenized-buffer-spec.coffee @@ -590,43 +590,56 @@ describe "TokenizedBuffer", -> iterator.seek(Point(0, 0)) expectedBoundaries = [ - {position: Point(0, 0), closeTags: [], openTags: ["syntax--source.syntax--js", "syntax--storage.syntax--type.syntax--var.syntax--js"]} - {position: Point(0, 3), closeTags: ["syntax--storage.syntax--type.syntax--var.syntax--js"], openTags: []} - {position: Point(0, 8), closeTags: [], openTags: ["syntax--keyword.syntax--operator.syntax--assignment.syntax--js"]} - {position: Point(0, 9), closeTags: ["syntax--keyword.syntax--operator.syntax--assignment.syntax--js"], openTags: []} - {position: Point(0, 10), closeTags: [], openTags: ["syntax--constant.syntax--numeric.syntax--decimal.syntax--js"]} - {position: Point(0, 11), closeTags: ["syntax--constant.syntax--numeric.syntax--decimal.syntax--js"], openTags: []} - {position: Point(0, 12), closeTags: [], openTags: ["syntax--comment.syntax--block.syntax--js", "syntax--punctuation.syntax--definition.syntax--comment.syntax--js"]} - {position: Point(0, 14), closeTags: ["syntax--punctuation.syntax--definition.syntax--comment.syntax--js"], openTags: []} - {position: Point(1, 5), closeTags: [], openTags: ["syntax--punctuation.syntax--definition.syntax--comment.syntax--js"]} - {position: Point(1, 7), closeTags: ["syntax--punctuation.syntax--definition.syntax--comment.syntax--js", "syntax--comment.syntax--block.syntax--js"], openTags: ["syntax--storage.syntax--type.syntax--var.syntax--js"]} - {position: Point(1, 10), closeTags: ["syntax--storage.syntax--type.syntax--var.syntax--js"], openTags: []} - {position: Point(1, 15), closeTags: [], openTags: ["syntax--keyword.syntax--operator.syntax--assignment.syntax--js"]} - {position: Point(1, 16), closeTags: ["syntax--keyword.syntax--operator.syntax--assignment.syntax--js"], openTags: []} - {position: Point(1, 17), closeTags: [], openTags: ["syntax--constant.syntax--numeric.syntax--decimal.syntax--js"]} - {position: Point(1, 18), closeTags: ["syntax--constant.syntax--numeric.syntax--decimal.syntax--js"], openTags: []} + {position: Point(0, 0), closeTags: [], openTags: ["syntax--source syntax--js", "syntax--storage syntax--type syntax--var syntax--js"]} + {position: Point(0, 3), closeTags: ["syntax--storage syntax--type syntax--var syntax--js"], openTags: []} + {position: Point(0, 8), closeTags: [], openTags: ["syntax--keyword syntax--operator syntax--assignment syntax--js"]} + {position: Point(0, 9), closeTags: ["syntax--keyword syntax--operator syntax--assignment syntax--js"], openTags: []} + {position: Point(0, 10), closeTags: [], openTags: ["syntax--constant syntax--numeric syntax--decimal syntax--js"]} + {position: Point(0, 11), closeTags: ["syntax--constant syntax--numeric syntax--decimal syntax--js"], openTags: []} + {position: Point(0, 12), closeTags: [], openTags: ["syntax--comment syntax--block syntax--js", "syntax--punctuation syntax--definition syntax--comment syntax--js"]} + {position: Point(0, 14), closeTags: ["syntax--punctuation syntax--definition syntax--comment syntax--js"], openTags: []} + {position: Point(1, 5), closeTags: [], openTags: ["syntax--punctuation syntax--definition syntax--comment syntax--js"]} + {position: Point(1, 7), closeTags: ["syntax--punctuation syntax--definition syntax--comment syntax--js", "syntax--comment syntax--block syntax--js"], openTags: ["syntax--storage syntax--type syntax--var syntax--js"]} + {position: Point(1, 10), closeTags: ["syntax--storage syntax--type syntax--var syntax--js"], openTags: []} + {position: Point(1, 15), closeTags: [], openTags: ["syntax--keyword syntax--operator syntax--assignment syntax--js"]} + {position: Point(1, 16), closeTags: ["syntax--keyword syntax--operator syntax--assignment syntax--js"], openTags: []} + {position: Point(1, 17), closeTags: [], openTags: ["syntax--constant syntax--numeric syntax--decimal syntax--js"]} + {position: Point(1, 18), closeTags: ["syntax--constant syntax--numeric syntax--decimal syntax--js"], openTags: []} ] loop boundary = { position: iterator.getPosition(), - closeTags: iterator.getCloseTags(), - openTags: iterator.getOpenTags() + closeTags: iterator.getCloseScopeIds().map((scopeId) -> tokenizedBuffer.classNameForScopeId(scopeId)), + openTags: iterator.getOpenScopeIds().map((scopeId) -> tokenizedBuffer.classNameForScopeId(scopeId)) } expect(boundary).toEqual(expectedBoundaries.shift()) break unless iterator.moveToSuccessor() - expect(iterator.seek(Point(0, 1))).toEqual(["syntax--source.syntax--js", "syntax--storage.syntax--type.syntax--var.syntax--js"]) + expect(iterator.seek(Point(0, 1)).map((scopeId) -> tokenizedBuffer.classNameForScopeId(scopeId))).toEqual([ + "syntax--source syntax--js", + "syntax--storage syntax--type syntax--var syntax--js" + ]) expect(iterator.getPosition()).toEqual(Point(0, 3)) - expect(iterator.seek(Point(0, 8))).toEqual(["syntax--source.syntax--js"]) + expect(iterator.seek(Point(0, 8)).map((scopeId) -> tokenizedBuffer.classNameForScopeId(scopeId))).toEqual([ + "syntax--source syntax--js" + ]) expect(iterator.getPosition()).toEqual(Point(0, 8)) - expect(iterator.seek(Point(1, 0))).toEqual(["syntax--source.syntax--js", "syntax--comment.syntax--block.syntax--js"]) + expect(iterator.seek(Point(1, 0)).map((scopeId) -> tokenizedBuffer.classNameForScopeId(scopeId))).toEqual([ + "syntax--source syntax--js", + "syntax--comment syntax--block syntax--js" + ]) expect(iterator.getPosition()).toEqual(Point(1, 0)) - expect(iterator.seek(Point(1, 18))).toEqual(["syntax--source.syntax--js", "syntax--constant.syntax--numeric.syntax--decimal.syntax--js"]) + expect(iterator.seek(Point(1, 18)).map((scopeId) -> tokenizedBuffer.classNameForScopeId(scopeId))).toEqual([ + "syntax--source syntax--js", + "syntax--constant syntax--numeric syntax--decimal syntax--js" + ]) expect(iterator.getPosition()).toEqual(Point(1, 18)) - expect(iterator.seek(Point(2, 0))).toEqual(["syntax--source.syntax--js"]) + expect(iterator.seek(Point(2, 0)).map((scopeId) -> tokenizedBuffer.classNameForScopeId(scopeId))).toEqual([ + "syntax--source syntax--js" + ]) iterator.moveToSuccessor() # ensure we don't infinitely loop (regression test) it "does not report columns beyond the length of the line", -> @@ -671,5 +684,5 @@ describe "TokenizedBuffer", -> iterator.seek(Point(1, 0)) expect(iterator.getPosition()).toEqual([1, 0]) - expect(iterator.getCloseTags()).toEqual ['syntax--blue.syntax--broken'] - expect(iterator.getOpenTags()).toEqual ['syntax--yellow.syntax--broken'] + expect(iterator.getCloseScopeIds().map((scopeId) -> tokenizedBuffer.classNameForScopeId(scopeId))).toEqual ['syntax--blue syntax--broken'] + expect(iterator.getOpenScopeIds().map((scopeId) -> tokenizedBuffer.classNameForScopeId(scopeId))).toEqual ['syntax--yellow syntax--broken'] diff --git a/src/first-mate-helpers.js b/src/first-mate-helpers.js new file mode 100644 index 00000000000..826c47fa0db --- /dev/null +++ b/src/first-mate-helpers.js @@ -0,0 +1,11 @@ +module.exports = { + fromFirstMateScopeId (firstMateScopeId) { + let atomScopeId = -firstMateScopeId + if ((atomScopeId & 1) === 0) atomScopeId-- + return atomScopeId + }, + + toFirstMateScopeId (atomScopeId) { + return -atomScopeId + } +} diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 56d008fa26a..4892a5cdc3a 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -3407,24 +3407,23 @@ class LineComponent { const textNodes = [] textNodesByScreenLineId.set(screenLine.id, textNodes) - const {lineText, tagCodes} = screenLine + const {lineText, tags} = screenLine let startIndex = 0 let openScopeNode = document.createElement('span') this.element.appendChild(openScopeNode) - for (let i = 0; i < tagCodes.length; i++) { - const tagCode = tagCodes[i] - if (tagCode !== 0) { - if (displayLayer.isCloseTagCode(tagCode)) { + for (let i = 0; i < tags.length; i++) { + const tag = tags[i] + if (tag !== 0) { + if (displayLayer.isCloseTag(tag)) { openScopeNode = openScopeNode.parentElement - } else if (displayLayer.isOpenTagCode(tagCode)) { - const scope = displayLayer.tagForCode(tagCode) + } else if (displayLayer.isOpenTag(tag)) { const newScopeNode = document.createElement('span') - newScopeNode.className = classNameForScopeName(scope) + newScopeNode.className = displayLayer.classNameForTag(tag) openScopeNode.appendChild(newScopeNode) openScopeNode = newScopeNode } else { - const textNode = document.createTextNode(lineText.substr(startIndex, tagCode)) - startIndex = startIndex + tagCode + const textNode = document.createTextNode(lineText.substr(startIndex, tag)) + startIndex = startIndex + tag openScopeNode.appendChild(textNode) textNodes.push(textNode) } @@ -3628,16 +3627,6 @@ class OverlayComponent { } } -const classNamesByScopeName = new Map() -function classNameForScopeName (scopeName) { - let classString = classNamesByScopeName.get(scopeName) - if (classString == null) { - classString = scopeName.replace(/\.+/g, ' ') - classNamesByScopeName.set(scopeName, classString) - } - return classString -} - let rangeForMeasurement function clientRectForRange (textNode, startIndex, endIndex) { if (!rangeForMeasurement) rangeForMeasurement = document.createRange() diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 4864663fe06..8b171eb675d 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -214,7 +214,7 @@ class TextEditor extends Model @disposables.add new Disposable => cancelIdleCallback(@backgroundWorkHandle) if @backgroundWorkHandle? - @displayLayer.setTextDecorationLayer(@tokenizedBuffer) + @displayLayer.addTextDecorationLayer(@tokenizedBuffer) @defaultMarkerLayer = @displayLayer.addMarkerLayer() @disposables.add(@defaultMarkerLayer.onDidDestroy => @assert(false, "defaultMarkerLayer destroyed at an unexpected time") diff --git a/src/tokenized-buffer-iterator.js b/src/tokenized-buffer-iterator.js index 540b4ad3a2c..908dd53aea2 100644 --- a/src/tokenized-buffer-iterator.js +++ b/src/tokenized-buffer-iterator.js @@ -1,120 +1,121 @@ const {Point} = require('text-buffer') - -const prefixedScopes = new Map() +const {fromFirstMateScopeId} = require('./first-mate-helpers') module.exports = class TokenizedBufferIterator { constructor (tokenizedBuffer) { this.tokenizedBuffer = tokenizedBuffer - this.openTags = null - this.closeTags = null - this.containingTags = null + this.openScopeIds = null + this.closeScopeIds = null + this.containingScopeIds = null } seek (position) { - this.openTags = [] - this.closeTags = [] + this.openScopeIds = [] + this.closeScopeIds = [] this.tagIndex = null const currentLine = this.tokenizedBuffer.tokenizedLineForRow(position.row) - this.currentTags = currentLine.tags + this.currentLineTags = currentLine.tags this.currentLineOpenTags = currentLine.openScopes this.currentLineLength = currentLine.text.length - this.containingTags = this.currentLineOpenTags.map((id) => this.scopeForId(id)) + this.containingScopeIds = this.currentLineOpenTags.map((id) => fromFirstMateScopeId(id)) let currentColumn = 0 - for (let [index, tag] of this.currentTags.entries()) { + for (let index = 0; index < this.currentLineTags.length; index++) { + const tag = this.currentLineTags[index] if (tag >= 0) { if (currentColumn >= position.column) { this.tagIndex = index break } else { currentColumn += tag - while (this.closeTags.length > 0) { - this.closeTags.shift() - this.containingTags.pop() + while (this.closeScopeIds.length > 0) { + this.closeScopeIds.shift() + this.containingScopeIds.pop() } - while (this.openTags.length > 0) { - const openTag = this.openTags.shift() - this.containingTags.push(openTag) + while (this.openScopeIds.length > 0) { + const openTag = this.openScopeIds.shift() + this.containingScopeIds.push(openTag) } } } else { - const scopeName = this.scopeForId(tag) - if (tag % 2 === 0) { - if (this.openTags.length > 0) { + const scopeId = fromFirstMateScopeId(tag) + if ((tag & 1) === 0) { + if (this.openScopeIds.length > 0) { if (currentColumn >= position.column) { this.tagIndex = index break } else { - while (this.closeTags.length > 0) { - this.closeTags.shift() - this.containingTags.pop() + while (this.closeScopeIds.length > 0) { + this.closeScopeIds.shift() + this.containingScopeIds.pop() } - while (this.openTags.length > 0) { - const openTag = this.openTags.shift() - this.containingTags.push(openTag) + while (this.openScopeIds.length > 0) { + const openTag = this.openScopeIds.shift() + this.containingScopeIds.push(openTag) } } } - this.closeTags.push(scopeName) + this.closeScopeIds.push(scopeId) } else { - this.openTags.push(scopeName) + this.openScopeIds.push(scopeId) } } } if (this.tagIndex == null) { - this.tagIndex = this.currentTags.length + this.tagIndex = this.currentLineTags.length } this.position = Point(position.row, Math.min(this.currentLineLength, currentColumn)) - return this.containingTags.slice() + return this.containingScopeIds.slice() } moveToSuccessor () { - for (let tag of this.closeTags) { // eslint-disable-line no-unused-vars - this.containingTags.pop() + for (let i = 0; i < this.closeScopeIds.length; i++) { + this.containingScopeIds.pop() } - for (let tag of this.openTags) { - this.containingTags.push(tag) + for (let i = 0; i < this.openScopeIds.length; i++) { + const tag = this.openScopeIds[i] + this.containingScopeIds.push(tag) } - this.openTags = [] - this.closeTags = [] + this.openScopeIds = [] + this.closeScopeIds = [] while (true) { - if (this.tagIndex === this.currentTags.length) { + if (this.tagIndex === this.currentLineTags.length) { if (this.isAtTagBoundary()) { break } else if (this.shouldMoveToNextLine) { this.moveToNextLine() - this.openTags = this.currentLineOpenTags.map((id) => this.scopeForId(id)) + this.openScopeIds = this.currentLineOpenTags.map((id) => fromFirstMateScopeId(id)) this.shouldMoveToNextLine = false } else if (this.nextLineHasMismatchedContainingTags()) { - this.closeTags = this.containingTags.slice().reverse() - this.containingTags = [] + this.closeScopeIds = this.containingScopeIds.slice().reverse() + this.containingScopeIds = [] this.shouldMoveToNextLine = true } else if (!this.moveToNextLine()) { return false } } else { - const tag = this.currentTags[this.tagIndex] + const tag = this.currentLineTags[this.tagIndex] if (tag >= 0) { if (this.isAtTagBoundary()) { break } else { this.position = Point(this.position.row, Math.min( this.currentLineLength, - this.position.column + this.currentTags[this.tagIndex] + this.position.column + this.currentLineTags[this.tagIndex] )) } } else { - const scopeName = this.scopeForId(tag) - if (tag % 2 === 0) { - if (this.openTags.length > 0) { + const scopeId = fromFirstMateScopeId(tag) + if ((tag & 1) === 0) { + if (this.openScopeIds.length > 0) { break } else { - this.closeTags.push(scopeName) + this.closeScopeIds.push(scopeId) } } else { - this.openTags.push(scopeName) + this.openScopeIds.push(scopeId) } } this.tagIndex++ @@ -127,12 +128,12 @@ module.exports = class TokenizedBufferIterator { return this.position } - getCloseTags () { - return this.closeTags.slice() + getCloseScopeIds () { + return this.closeScopeIds.slice() } - getOpenTags () { - return this.openTags.slice() + getOpenScopeIds () { + return this.openScopeIds.slice() } nextLineHasMismatchedContainingTags () { @@ -141,8 +142,8 @@ module.exports = class TokenizedBufferIterator { return false } else { return ( - this.containingTags.length !== line.openScopes.length || - this.containingTags.some((tag, i) => tag !== this.scopeForId(line.openScopes[i])) + this.containingScopeIds.length !== line.openScopes.length || + this.containingScopeIds.some((tag, i) => tag !== fromFirstMateScopeId(line.openScopes[i])) ) } } @@ -153,7 +154,7 @@ module.exports = class TokenizedBufferIterator { if (tokenizedLine == null) { return false } else { - this.currentTags = tokenizedLine.tags + this.currentLineTags = tokenizedLine.tags this.currentLineLength = tokenizedLine.text.length this.currentLineOpenTags = tokenizedLine.openScopes this.tagIndex = 0 @@ -162,22 +163,6 @@ module.exports = class TokenizedBufferIterator { } isAtTagBoundary () { - return this.closeTags.length > 0 || this.openTags.length > 0 - } - - scopeForId (id) { - const scope = this.tokenizedBuffer.grammar.scopeForId(id) - if (scope) { - let prefixedScope = prefixedScopes.get(scope) - if (prefixedScope) { - return prefixedScope - } else { - prefixedScope = `syntax--${scope.replace(/\./g, '.syntax--')}` - prefixedScopes.set(scope, prefixedScope) - return prefixedScope - } - } else { - return null - } + return this.closeScopeIds.length > 0 || this.openScopeIds.length > 0 } } diff --git a/src/tokenized-buffer.coffee b/src/tokenized-buffer.coffee index 234f82be99f..a828950b38c 100644 --- a/src/tokenized-buffer.coffee +++ b/src/tokenized-buffer.coffee @@ -7,6 +7,9 @@ TokenIterator = require './token-iterator' ScopeDescriptor = require './scope-descriptor' TokenizedBufferIterator = require './tokenized-buffer-iterator' NullGrammar = require './null-grammar' +{toFirstMateScopeId} = require './first-mate-helpers' + +prefixedScopes = new Map() module.exports = class TokenizedBuffer extends Model @@ -46,6 +49,19 @@ class TokenizedBuffer extends Model buildIterator: -> new TokenizedBufferIterator(this) + classNameForScopeId: (id) -> + scope = @grammar.scopeForId(toFirstMateScopeId(id)) + if scope + prefixedScope = prefixedScopes.get(scope) + if prefixedScope + prefixedScope + else + prefixedScope = "syntax--#{scope.replace(/\./g, ' syntax--')}" + prefixedScopes.set(scope, prefixedScope) + prefixedScope + else + null + getInvalidatedRanges: -> [] From ac8a9083856e52748a0e31a79843117d52b963a7 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 3 May 2017 14:54:25 -0600 Subject: [PATCH 328/403] Implement text decorations in rendering layer --- spec/text-editor-component-spec.js | 52 ++++++ src/text-editor-component.js | 270 +++++++++++++++++++++++++---- 2 files changed, 289 insertions(+), 33 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 258be57aab2..b2e8259868d 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1951,6 +1951,58 @@ describe('TextEditorComponent', () => { }) }) + describe('text decorations', () => { + it('injects spans with custom class names and inline styles based on text decorations', async () => { + const {component, element, editor} = buildComponent() + + const markerLayer = editor.addMarkerLayer() + + const marker1 = markerLayer.markBufferRange([[0, 2], [2, 7]]) + const marker2 = markerLayer.markBufferRange([[0, 2], [3, 8]]) + const marker3 = markerLayer.markBufferRange([[1, 13], [2, 7]]) + + editor.decorateMarker(marker1, {type: 'text', class: 'a', style: {color: 'red'}}) + editor.decorateMarker(marker2, {type: 'text', class: 'b', style: {color: 'blue'}}) + editor.decorateMarker(marker3, {type: 'text', class: 'c', style: {color: 'green'}}) + await component.getNextUpdatePromise() + + expect(textContentOnRowMatchingSelector(component, 0, '.a')).toBe(editor.lineTextForScreenRow(0).slice(2)) + expect(textContentOnRowMatchingSelector(component, 1, '.a')).toBe(editor.lineTextForScreenRow(1)) + expect(textContentOnRowMatchingSelector(component, 2, '.a')).toBe(editor.lineTextForScreenRow(2).slice(0, 7)) + expect(textContentOnRowMatchingSelector(component, 3, '.a')).toBe('') + + expect(textContentOnRowMatchingSelector(component, 0, '.b')).toBe(editor.lineTextForScreenRow(0).slice(2)) + expect(textContentOnRowMatchingSelector(component, 1, '.b')).toBe(editor.lineTextForScreenRow(1)) + expect(textContentOnRowMatchingSelector(component, 2, '.b')).toBe(editor.lineTextForScreenRow(2)) + expect(textContentOnRowMatchingSelector(component, 3, '.b')).toBe(editor.lineTextForScreenRow(3).slice(0, 8)) + + expect(textContentOnRowMatchingSelector(component, 0, '.c')).toBe('') + expect(textContentOnRowMatchingSelector(component, 1, '.c')).toBe(editor.lineTextForScreenRow(1).slice(13)) + expect(textContentOnRowMatchingSelector(component, 2, '.c')).toBe(editor.lineTextForScreenRow(2).slice(0, 7)) + expect(textContentOnRowMatchingSelector(component, 3, '.c')).toBe('') + + for (const span of element.querySelectorAll('.a:not(.c)')) { + expect(span.style.color).toBe('red') + } + for (const span of element.querySelectorAll('.b:not(.c):not(.a)')) { + expect(span.style.color).toBe('blue') + } + for (const span of element.querySelectorAll('.c')) { + expect(span.style.color).toBe('green') + } + + marker2.setHeadScreenPosition([3, 10]) + await component.getNextUpdatePromise() + expect(textContentOnRowMatchingSelector(component, 3, '.b')).toBe(editor.lineTextForScreenRow(3).slice(0, 10)) + }) + + function textContentOnRowMatchingSelector (component, row, selector) { + return Array.from(lineNodeForScreenRow(component, row).querySelectorAll(selector)) + .map((span) => span.textContent) + .join('') + } + }) + describe('mouse input', () => { describe('on the lines', () => { it('positions the cursor on single-click', async () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 4892a5cdc3a..57691e03960 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -138,12 +138,15 @@ class TextEditorComponent { cursors: [], overlays: [], customGutter: new Map(), - blocks: new Map() + blocks: new Map(), + text: [] } this.decorationsToMeasure = { highlights: new Map(), cursors: new Map() } + this.textDecorationsByMarker = new Map() + this.textDecorationBoundaries = [] this.pendingScrollTopRow = this.props.initialScrollTopRow this.pendingScrollLeftColumn = this.props.initialScrollLeftColumn @@ -559,6 +562,7 @@ class TextEditorComponent { tileEndRow, screenLines: this.renderedScreenLines.slice(tileStartRow - startRow, tileEndRow - startRow), lineDecorations: this.decorationsToRender.lines.slice(tileStartRow - startRow, tileEndRow - startRow), + textDecorations: this.decorationsToRender.text.slice(tileStartRow - startRow, tileEndRow - startRow), blockDecorations: this.decorationsToRender.blocks.get(tileStartRow), highlightDecorations: this.decorationsToRender.highlights.get(tileStartRow), displayLayer, @@ -871,8 +875,11 @@ class TextEditorComponent { this.decorationsToRender.overlays.length = 0 this.decorationsToRender.customGutter.clear() this.decorationsToRender.blocks = new Map() + this.decorationsToRender.text = [] this.decorationsToMeasure.highlights.clear() this.decorationsToMeasure.cursors.clear() + this.textDecorationsByMarker.clear() + this.textDecorationBoundaries.length = 0 const decorationsByMarker = this.props.model.decorationManager.decorationPropertiesByMarkerForScreenRowRange( @@ -888,6 +895,8 @@ class TextEditorComponent { this.addDecorationToRender(decoration.type, decoration, marker, screenRange, reversed) } }) + + this.populateTextDecorationsToRender() } addDecorationToRender (type, decoration, marker, screenRange, reversed) { @@ -916,6 +925,9 @@ class TextEditorComponent { case 'block': this.addBlockDecorationToRender(decoration, screenRange, reversed) break + case 'text': + this.addTextDecorationToRender(decoration, screenRange, marker) + break } } } @@ -1078,6 +1090,122 @@ class TextEditorComponent { decorations.push(decoration) } + addTextDecorationToRender (decoration, screenRange, marker) { + if (screenRange.isEmpty()) return + + let decorationsForMarker = this.textDecorationsByMarker.get(marker) + if (!decorationsForMarker) { + decorationsForMarker = [] + this.textDecorationsByMarker.set(marker, decorationsForMarker) + this.textDecorationBoundaries.push({position: screenRange.start, starting: [marker]}) + this.textDecorationBoundaries.push({position: screenRange.end, ending: [marker]}) + } + decorationsForMarker.push(decoration) + } + + populateTextDecorationsToRender () { + // Sort all boundaries in ascending order of position + this.textDecorationBoundaries.sort((a, b) => a.position.compare(b.position)) + + // Combine adjacent boundaries with the same position + for (let i = 0; i < this.textDecorationBoundaries.length;) { + const boundary = this.textDecorationBoundaries[i] + const nextBoundary = this.textDecorationBoundaries[i + 1] + if (nextBoundary && nextBoundary.position.isEqual(boundary.position)) { + if (nextBoundary.starting) { + if (boundary.starting) { + boundary.starting.push(...nextBoundary.starting) + } else { + boundary.starting = nextBoundary.starting + } + } + + if (nextBoundary.ending) { + if (boundary.ending) { + boundary.ending.push(...nextBoundary.ending) + } else { + boundary.ending = nextBoundary.ending + } + } + + this.textDecorationBoundaries.splice(i + 1, 1) + } else { + i++ + } + } + + const renderedStartRow = this.getRenderedStartRow() + const containingMarkers = [] + + // Iterate over boundaries to build up text decorations. + for (let i = 0; i < this.textDecorationBoundaries.length; i++) { + const boundary = this.textDecorationBoundaries[i] + + // If multiple markers start here, sort them by order of nesting (markers ending later come first) + if (boundary.starting && boundary.starting.length > 1) { + boundary.starting.sort((a, b) => a.compare(b)) + } + + // If multiple markers start here, sort them by order of nesting (markers starting earlier come first) + if (boundary.ending && boundary.ending.length > 1) { + boundary.ending.sort((a, b) => b.compare(a)) + } + + // Remove markers ending here from containing markers array + if (boundary.ending) { + for (let j = boundary.ending.length - 1; j >= 0; j--) { + containingMarkers.splice(containingMarkers.lastIndexOf(boundary.ending[j]), 1) + } + } + // Add markers starting here to containing markers array + if (boundary.starting) containingMarkers.push(...boundary.starting) + + // Determine desired className and style based on containing markers + let className, style + for (let j = 0; j < containingMarkers.length; j++) { + const marker = containingMarkers[j] + const decorations = this.textDecorationsByMarker.get(marker) + for (let k = 0; k < decorations.length; k++) { + const decoration = decorations[k] + if (decoration.class) { + if (className) { + className += ' ' + decoration.class + } else { + className = decoration.class + } + } + if (decoration.style) { + if (style) { + Object.assign(style, decoration.style) + } else { + style = Object.assign({}, decoration.style) + } + } + } + } + + // Add decoration start with className/style for current position's column, + // and also for the start of every row up until the next decoration boundary + this.addTextDecorationStart(boundary.position.row, boundary.position.column, className, style) + const nextBoundary = this.textDecorationBoundaries[i + 1] + if (nextBoundary) { + for (let row = boundary.position.row + 1; row <= nextBoundary.position.row; row++) { + this.addTextDecorationStart(row, 0, className, style) + } + } + } + } + + addTextDecorationStart (row, column, className, style) { + const renderedStartRow = this.getRenderedStartRow() + let decorationStarts = this.decorationsToRender.text[row - renderedStartRow] + if (!decorationStarts) { + decorationStarts = [] + this.decorationsToRender.text[row - renderedStartRow] = decorationStarts + } + decorationStarts.push({column, className, style}) + } + updateAbsolutePositionedDecorations () { this.updateHighlightsToRender() this.updateCursorsToRender() @@ -3148,7 +3276,7 @@ class LinesTileComponent { createLines () { const { - tileStartRow, screenLines, lineDecorations, + tileStartRow, screenLines, lineDecorations, textDecorations, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId } = this.props @@ -3158,6 +3286,7 @@ class LinesTileComponent { screenLine: screenLines[i], screenRow: tileStartRow + i, lineDecoration: lineDecorations[i], + textDecorations: textDecorations[i], displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId @@ -3169,7 +3298,7 @@ class LinesTileComponent { updateLines (oldProps, newProps) { var { - screenLines, tileStartRow, lineDecorations, + screenLines, tileStartRow, lineDecorations, textDecorations, displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId } = newProps @@ -3190,6 +3319,7 @@ class LinesTileComponent { screenLine: newScreenLine, screenRow: tileStartRow + newScreenLineIndex, lineDecoration: lineDecorations[newScreenLineIndex], + textDecorations: textDecorations[newScreenLineIndex], displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId @@ -3208,7 +3338,8 @@ class LinesTileComponent { var lineComponent = this.lineComponents[lineComponentIndex] lineComponent.update({ screenRow: tileStartRow + newScreenLineIndex, - lineDecoration: lineDecorations[newScreenLineIndex] + lineDecoration: lineDecorations[newScreenLineIndex], + textDecorations: textDecorations[newScreenLineIndex] }) oldScreenLineIndex++ @@ -3224,6 +3355,7 @@ class LinesTileComponent { screenLine: newScreenLines[newScreenLineIndex], screenRow: tileStartRow + newScreenLineIndex, lineDecoration: lineDecorations[newScreenLineIndex], + textDecorations: textDecorations[newScreenLineIndex], displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId @@ -3249,6 +3381,7 @@ class LinesTileComponent { screenLine: newScreenLines[newScreenLineIndex], screenRow: tileStartRow + newScreenLineIndex, lineDecoration: lineDecorations[newScreenLineIndex], + textDecorations: textDecorations[newScreenLineIndex], displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId @@ -3387,30 +3520,74 @@ class LinesTileComponent { return true } + if (oldProps.textDecorations.length !== newProps.textDecorations.length) return true + for (let i = 0; i < oldProps.textDecorations.length; i++) { + if (!textDecorationsEqual(oldProps.textDecorations[i], newProps.textDecorations[i])) return true + } + return false } } class LineComponent { constructor (props) { - const { - displayLayer, - screenLine, screenRow, - lineNodesByScreenLineId, textNodesByScreenLineId - } = props + const {screenRow, screenLine, lineNodesByScreenLineId} = props this.props = props this.element = document.createElement('div') this.element.className = this.buildClassName() this.element.dataset.screenRow = screenRow lineNodesByScreenLineId.set(screenLine.id, this.element) + this.appendContents() + } + + update (newProps) { + if (this.props.lineDecoration !== newProps.lineDecoration) { + this.props.lineDecoration = newProps.lineDecoration + this.element.className = this.buildClassName() + } + + if (this.props.screenRow !== newProps.screenRow) { + this.props.screenRow = newProps.screenRow + this.element.dataset.screenRow = newProps.screenRow + } + + if (!textDecorationsEqual(this.props.textDecorations, newProps.textDecorations)) { + this.props.textDecorations = newProps.textDecorations + this.element.firstChild.remove() + this.appendContents() + } + } + + destroy () { + const {lineNodesByScreenLineId, textNodesByScreenLineId, screenLine} = this.props + if (lineNodesByScreenLineId.get(screenLine.id) === this.element) { + lineNodesByScreenLineId.delete(screenLine.id) + textNodesByScreenLineId.delete(screenLine.id) + } + + this.element.remove() + } + + appendContents () { + const {displayLayer, screenLine, textDecorations, textNodesByScreenLineId} = this.props const textNodes = [] textNodesByScreenLineId.set(screenLine.id, textNodes) const {lineText, tags} = screenLine - let startIndex = 0 let openScopeNode = document.createElement('span') this.element.appendChild(openScopeNode) + + let decorationIndex = 0 + let column = 0 + let activeClassName = null + let activeStyle = null + let nextDecoration = textDecorations ? textDecorations[decorationIndex] : null + if (nextDecoration && nextDecoration.column === 0) { + ({className: activeClassName, style: activeStyle} = nextDecoration) + nextDecoration = textDecorations[++decorationIndex] + } + for (let i = 0; i < tags.length; i++) { const tag = tags[i] if (tag !== 0) { @@ -3422,15 +3599,22 @@ class LineComponent { openScopeNode.appendChild(newScopeNode) openScopeNode = newScopeNode } else { - const textNode = document.createTextNode(lineText.substr(startIndex, tag)) - startIndex = startIndex + tag - openScopeNode.appendChild(textNode) - textNodes.push(textNode) + const nextTokenColumn = column + tag + while (nextDecoration && nextDecoration.column <= nextTokenColumn) { + const text = lineText.substring(column, nextDecoration.column) + this.appendTextNode(textNodes, openScopeNode, text, activeClassName, activeStyle) + ,({column, className: activeClassName, style: activeStyle} = nextDecoration) + nextDecoration = textDecorations[++decorationIndex] + } + + const text = lineText.substring(column, nextTokenColumn) + this.appendTextNode(textNodes, openScopeNode, text, activeClassName, activeStyle) + column = nextTokenColumn } } } - if (startIndex === 0) { + if (column === 0) { const textNode = document.createTextNode(' ') this.element.appendChild(textNode) textNodes.push(textNode) @@ -3446,26 +3630,18 @@ class LineComponent { } } - update (newProps) { - if (this.props.lineDecoration !== newProps.lineDecoration) { - this.props.lineDecoration = newProps.lineDecoration - this.element.className = this.buildClassName() - } - - if (this.props.screenRow !== newProps.screenRow) { - this.props.screenRow = newProps.screenRow - this.element.dataset.screenRow = newProps.screenRow - } - } - - destroy () { - const {lineNodesByScreenLineId, textNodesByScreenLineId, screenLine} = this.props - if (lineNodesByScreenLineId.get(screenLine.id) === this.element) { - lineNodesByScreenLineId.delete(screenLine.id) - textNodesByScreenLineId.delete(screenLine.id) + appendTextNode (textNodes, openScopeNode, text, activeClassName, activeStyle) { + if (activeClassName || activeStyle) { + const decorationNode = document.createElement('span') + if (activeClassName) decorationNode.className = activeClassName + if (activeStyle) Object.assign(decorationNode.style, activeStyle) + openScopeNode.appendChild(decorationNode) + openScopeNode = decorationNode } - this.element.remove() + const textNode = document.createTextNode(text) + openScopeNode.appendChild(textNode) + textNodes.push(textNode) } buildClassName () { @@ -3635,6 +3811,20 @@ function clientRectForRange (textNode, startIndex, endIndex) { return rangeForMeasurement.getBoundingClientRect() } +function textDecorationsEqual (oldDecorations, newDecorations) { + if (!oldDecorations && newDecorations) return false + if (oldDecorations && !newDecorations) return false + if (oldDecorations && newDecorations) { + if (oldDecorations.length !== newDecorations.length) return false + for (let j = 0; j < oldDecorations.length; j++) { + if (oldDecorations[j].column !== newDecorations[j].column) return false + if (oldDecorations[j].className !== newDecorations[j].className) return false + if (!objectsEqual(oldDecorations[j].style, newDecorations[j].style)) return false + } + } + return true +} + function arraysEqual (a, b) { if (a.length !== b.length) return false for (let i = 0, length = a.length; i < length; i++) { @@ -3643,6 +3833,20 @@ function arraysEqual (a, b) { return true } +function objectsEqual (a, b) { + if (!a && b) return false + if (a && !b) return false + if (a && b) { + for (key in a) { + if (a[key] !== b[key]) return false + } + for (key in b) { + if (a[key] !== b[key]) return false + } + } + return true +} + function constrainRangeToRows (range, startRow, endRow) { if (range.start.row < startRow || range.end.row >= endRow) { range = range.copy() From c2b854123b838354908c2ee016c85dfd9bb4699f Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 4 May 2017 11:53:20 +0200 Subject: [PATCH 329/403] Never create empty spans at the beginning of a row This was happening when a text decoration overlapped a row, but the next boundary was located exactly at the beginning of it. --- spec/text-editor-component-spec.js | 44 ++++++++++++++++++++++++++++-- src/text-editor-component.js | 21 +++++++++++--- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index b2e8259868d..1de2a091aec 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1953,10 +1953,9 @@ describe('TextEditorComponent', () => { describe('text decorations', () => { it('injects spans with custom class names and inline styles based on text decorations', async () => { - const {component, element, editor} = buildComponent() + const {component, element, editor} = buildComponent({rowsPerTile: 2}) const markerLayer = editor.addMarkerLayer() - const marker1 = markerLayer.markBufferRange([[0, 2], [2, 7]]) const marker2 = markerLayer.markBufferRange([[0, 2], [3, 8]]) const marker3 = markerLayer.markBufferRange([[1, 13], [2, 7]]) @@ -1996,6 +1995,47 @@ describe('TextEditorComponent', () => { expect(textContentOnRowMatchingSelector(component, 3, '.b')).toBe(editor.lineTextForScreenRow(3).slice(0, 10)) }) + it('correctly handles text decorations starting before the first rendered row and/or ending after the last rendered row', async () => { + const {component, element, editor} = buildComponent({autoHeight: false, rowsPerTile: 1}) + element.style.height = 3 * component.getLineHeight() + 'px' + await component.getNextUpdatePromise() + await setScrollTop(component, 4 * component.getLineHeight()) + expect(component.getRenderedStartRow()).toBe(4) + expect(component.getRenderedEndRow()).toBe(9) + + const markerLayer = editor.addMarkerLayer() + const marker1 = markerLayer.markBufferRange([[0, 0], [4, 5]]) + const marker2 = markerLayer.markBufferRange([[7, 2], [10, 8]]) + editor.decorateMarker(marker1, {type: 'text', class: 'a'}) + editor.decorateMarker(marker2, {type: 'text', class: 'b'}) + await component.getNextUpdatePromise() + + expect(textContentOnRowMatchingSelector(component, 4, '.a')).toBe(editor.lineTextForScreenRow(4).slice(0, 5)) + expect(textContentOnRowMatchingSelector(component, 5, '.a')).toBe('') + expect(textContentOnRowMatchingSelector(component, 6, '.a')).toBe('') + expect(textContentOnRowMatchingSelector(component, 7, '.a')).toBe('') + expect(textContentOnRowMatchingSelector(component, 8, '.a')).toBe('') + + expect(textContentOnRowMatchingSelector(component, 4, '.b')).toBe('') + expect(textContentOnRowMatchingSelector(component, 5, '.b')).toBe('') + expect(textContentOnRowMatchingSelector(component, 6, '.b')).toBe('') + expect(textContentOnRowMatchingSelector(component, 7, '.b')).toBe(editor.lineTextForScreenRow(7).slice(2)) + expect(textContentOnRowMatchingSelector(component, 8, '.b')).toBe(editor.lineTextForScreenRow(8)) + }) + + it('does not create empty spans when a text decoration contains a row but another text decoration starts or ends at the beginning of it', async () => { + const {component, element, editor} = buildComponent() + const markerLayer = editor.addMarkerLayer() + const marker1 = markerLayer.markBufferRange([[0, 2], [4, 0]]) + const marker2 = markerLayer.markBufferRange([[2, 0], [5, 8]]) + editor.decorateMarker(marker1, {type: 'text', class: 'a'}) + editor.decorateMarker(marker2, {type: 'text', class: 'b'}) + await component.getNextUpdatePromise() + for (const decorationSpan of element.querySelectorAll('.a, .b')) { + expect(decorationSpan.textContent).not.toBe('') + } + }) + function textContentOnRowMatchingSelector (component, row, selector) { return Array.from(lineNodeForScreenRow(component, row).querySelectorAll(selector)) .map((span) => span.textContent) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 57691e03960..6049fb2a17d 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1135,6 +1135,7 @@ class TextEditorComponent { } const renderedStartRow = this.getRenderedStartRow() + const renderedEndRow = this.getRenderedEndRow() const containingMarkers = [] // Iterate over boundaries to build up text decorations. @@ -1186,10 +1187,18 @@ class TextEditorComponent { // Add decoration start with className/style for current position's column, // and also for the start of every row up until the next decoration boundary - this.addTextDecorationStart(boundary.position.row, boundary.position.column, className, style) + if (boundary.position.row >= renderedStartRow) { + this.addTextDecorationStart(boundary.position.row, boundary.position.column, className, style) + } const nextBoundary = this.textDecorationBoundaries[i + 1] if (nextBoundary) { - for (let row = boundary.position.row + 1; row <= nextBoundary.position.row; row++) { + let row = Math.max(boundary.position.row + 1, renderedStartRow) + const endRow = Math.min(nextBoundary.position.row, renderedEndRow) + for (; row < endRow; row++) { + this.addTextDecorationStart(row, 0, className, style) + } + + if (row === nextBoundary.position.row && nextBoundary.position.column !== 0) { this.addTextDecorationStart(row, 0, className, style) } } @@ -3584,7 +3593,9 @@ class LineComponent { let activeStyle = null let nextDecoration = textDecorations ? textDecorations[decorationIndex] : null if (nextDecoration && nextDecoration.column === 0) { - ({className: activeClassName, style: activeStyle} = nextDecoration) + column = nextDecoration.column + activeClassName = nextDecoration.className + activeStyle = nextDecoration.style nextDecoration = textDecorations[++decorationIndex] } @@ -3603,7 +3614,9 @@ class LineComponent { while (nextDecoration && nextDecoration.column <= nextTokenColumn) { const text = lineText.substring(column, nextDecoration.column) this.appendTextNode(textNodes, openScopeNode, text, activeClassName, activeStyle) - ,({column, className: activeClassName, style: activeStyle} = nextDecoration) + column = nextDecoration.column + activeClassName = nextDecoration.className + activeStyle = nextDecoration.style nextDecoration = textDecorations[++decorationIndex] } From ccc35b514137ecd170fce9ef22b9da9077583dba Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 4 May 2017 17:31:27 +0200 Subject: [PATCH 330/403] Make first-mate scope ids always larger than built-in ones --- src/first-mate-helpers.js | 4 ++-- src/text-editor.coffee | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/first-mate-helpers.js b/src/first-mate-helpers.js index 826c47fa0db..0ca312834bb 100644 --- a/src/first-mate-helpers.js +++ b/src/first-mate-helpers.js @@ -2,10 +2,10 @@ module.exports = { fromFirstMateScopeId (firstMateScopeId) { let atomScopeId = -firstMateScopeId if ((atomScopeId & 1) === 0) atomScopeId-- - return atomScopeId + return atomScopeId + 256 }, toFirstMateScopeId (atomScopeId) { - return -atomScopeId + return -(atomScopeId - 256) } } diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 8b171eb675d..4864663fe06 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -214,7 +214,7 @@ class TextEditor extends Model @disposables.add new Disposable => cancelIdleCallback(@backgroundWorkHandle) if @backgroundWorkHandle? - @displayLayer.addTextDecorationLayer(@tokenizedBuffer) + @displayLayer.setTextDecorationLayer(@tokenizedBuffer) @defaultMarkerLayer = @displayLayer.addMarkerLayer() @disposables.add(@defaultMarkerLayer.onDidDestroy => @assert(false, "defaultMarkerLayer destroyed at an unexpected time") From de2cfb5ef7441e1e18ffa781178a540f5655bee5 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 4 May 2017 19:36:38 +0200 Subject: [PATCH 331/403] :arrow_up: text-buffer --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6eb57d2fec5..f907162d50e 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "sinon": "1.17.4", "source-map-support": "^0.3.2", "temp": "^0.8.3", - "text-buffer": "11.4.1", + "text-buffer": "12.1.0", "typescript-simple": "1.0.0", "underscore-plus": "^1.6.6", "winreg": "^1.2.1", From 001fef4a05cd96cb568d7a0f3a37945b790f3a32 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 4 May 2017 14:36:42 +0200 Subject: [PATCH 332/403] Don't activate scrollPastEnd for autoHeight editors --- spec/text-editor-registry-spec.js | 2 +- spec/text-editor-spec.coffee | 6 ++++++ src/text-editor.coffee | 6 +++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/spec/text-editor-registry-spec.js b/spec/text-editor-registry-spec.js index ac5183cab1f..79d575a5f51 100644 --- a/spec/text-editor-registry-spec.js +++ b/spec/text-editor-registry-spec.js @@ -19,7 +19,7 @@ describe('TextEditorRegistry', function () { packageManager: {deferredActivationHooks: null} }) - editor = new TextEditor() + editor = new TextEditor({autoHeight: false}) }) afterEach(function () { diff --git a/spec/text-editor-spec.coffee b/spec/text-editor-spec.coffee index 3c2afc6abc0..99e8f497f14 100644 --- a/spec/text-editor-spec.coffee +++ b/spec/text-editor-spec.coffee @@ -5561,6 +5561,12 @@ describe "TextEditor", -> editor.update({scrollPastEnd: false}) expect(editor.getScrollPastEnd()).toBe(false) + it "always returns false when autoHeight is on", -> + editor.update({autoHeight: true, scrollPastEnd: true}) + expect(editor.getScrollPastEnd()).toBe(false) + editor.update({autoHeight: false}) + expect(editor.getScrollPastEnd()).toBe(true) + describe "auto height", -> it "returns true by default but can be customized", -> editor = new TextEditor diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 4864663fe06..248d87d8153 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -3546,7 +3546,11 @@ class TextEditor extends Model # Experimental: Does this editor allow scrolling past the last line? # # Returns a {Boolean}. - getScrollPastEnd: -> @scrollPastEnd + getScrollPastEnd: -> + if @getAutoHeight() + false + else + @scrollPastEnd # Experimental: How fast does the editor scroll in response to mouse wheel # movements? From fe132795315af166cc627c05c08cfc9cd118a9bf Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 4 May 2017 14:09:45 -0600 Subject: [PATCH 333/403] Update DOM in screenPositionForPixelPosition if needed Some packages are interacting with this method assuming this behavior, so this commit eliminates `screenPositionForPixelPositionSync` and instead just performs the DOM update in `screenPositionForPixelPosition` if it is needed. --- spec/text-editor-component-spec.js | 10 +++++----- src/text-editor-component.js | 25 ++++++++----------------- src/text-editor-element.js | 2 +- 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 1de2a091aec..8421708d639 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -3112,7 +3112,7 @@ describe('TextEditorComponent', () => { }) }) - describe('screenPositionForPixelPositionSync', () => { + describe('screenPositionForPixelPosition', () => { it('returns the screen position for the given pixel position, regardless of whether or not it is currently on screen', async () => { const {component, element, editor} = buildComponent({rowsPerTile: 2, autoHeight: false}) await setEditorHeightInLines(component, 3) @@ -3123,28 +3123,28 @@ describe('TextEditorComponent', () => { const pixelPosition = referenceComponent.pixelPositionForScreenPositionSync({row: 0, column: 0}) pixelPosition.top += component.getLineHeight() / 3 pixelPosition.left += component.getBaseCharacterWidth() / 3 - expect(component.screenPositionForPixelPositionSync(pixelPosition)).toEqual([0, 0]) + expect(component.screenPositionForPixelPosition(pixelPosition)).toEqual([0, 0]) } { const pixelPosition = referenceComponent.pixelPositionForScreenPositionSync({row: 0, column: 5}) pixelPosition.top += component.getLineHeight() / 3 pixelPosition.left += component.getBaseCharacterWidth() / 3 - expect(component.screenPositionForPixelPositionSync(pixelPosition)).toEqual([0, 5]) + expect(component.screenPositionForPixelPosition(pixelPosition)).toEqual([0, 5]) } { const pixelPosition = referenceComponent.pixelPositionForScreenPositionSync({row: 5, column: 7}) pixelPosition.top += component.getLineHeight() / 3 pixelPosition.left += component.getBaseCharacterWidth() / 3 - expect(component.screenPositionForPixelPositionSync(pixelPosition)).toEqual([5, 7]) + expect(component.screenPositionForPixelPosition(pixelPosition)).toEqual([5, 7]) } { const pixelPosition = referenceComponent.pixelPositionForScreenPositionSync({row: 12, column: 1}) pixelPosition.top += component.getLineHeight() / 3 pixelPosition.left += component.getBaseCharacterWidth() / 3 - expect(component.screenPositionForPixelPositionSync(pixelPosition)).toEqual([12, 1]) + expect(component.screenPositionForPixelPosition(pixelPosition)).toEqual([12, 1]) } }) }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 6049fb2a17d..3215db76053 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -173,22 +173,6 @@ class TextEditorComponent { return {top, left} } - screenPositionForPixelPositionSync (pixelPosition) { - const {model} = this.props - - const row = Math.max(0, Math.min( - this.rowForPixelPosition(pixelPosition.top), - model.getApproximateScreenLineCount() - 1 - )) - - if (!this.renderedScreenLineForRow(row)) { - this.requestExtraLineToMeasure(row, model.screenLineForScreenRow(row)) - this.updateSyncBeforeMeasuringContent() - this.measureContentDuringUpdateSync() - } - return this.screenPositionForPixelPosition(pixelPosition) - } - scheduleUpdate (nextUpdateOnlyBlinksCursors = false) { if (!this.visible) return @@ -2181,9 +2165,16 @@ class TextEditorComponent { model.getApproximateScreenLineCount() - 1 ) + let screenLine = this.renderedScreenLineForRow(row) + if (!screenLine) { + this.requestExtraLineToMeasure(row, model.screenLineForScreenRow(row)) + this.updateSyncBeforeMeasuringContent() + this.measureContentDuringUpdateSync() + screenLine = this.renderedScreenLineForRow(row) + } + const linesClientLeft = this.refs.lineTiles.getBoundingClientRect().left const targetClientLeft = linesClientLeft + Math.max(0, left) - const screenLine = this.renderedScreenLineForRow(row) const textNodes = this.textNodesByScreenLineId.get(screenLine.id) let containingTextNodeIndex diff --git a/src/text-editor-element.js b/src/text-editor-element.js index 04e22447f38..0c8b50a626f 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -245,7 +245,7 @@ class TextEditorElement extends HTMLElement { } screenPositionForPixelPosition (pixelPosition) { - return this.getComponent().screenPositionForPixelPositionSync(pixelPosition) + return this.getComponent().screenPositionForPixelPosition(pixelPosition) } pixelRectForScreenRange (range) { From 42bb02c8a897526924c5a0d2723b39c44c437eb1 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 4 May 2017 15:14:47 -0600 Subject: [PATCH 334/403] Account for vertical scrollbar width when soft-wrapping lines --- spec/text-editor-component-spec.js | 8 ++++++++ src/text-editor-component.js | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 8421708d639..f4abd9544af 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -418,6 +418,14 @@ describe('TextEditorComponent', () => { expect(scrollContainer.clientWidth).toBe(scrollContainer.scrollWidth) }) + it('accounts for the width of the vertical scrollbar when soft-wrapping lines', async () => { + const {component, element, editor} = buildComponent({height: 200, width: 200, attach: false, text: 'a'.repeat(300)}) + editor.setSoftWrapped(true) + jasmine.attachToDOM(element) + expect(Math.floor(component.getScrollContainerClientWidth() / component.getBaseCharacterWidth())).toBe(20) + expect(editor.lineLengthForScreenRow(0)).toBe(20) + }) + it('decorates the line numbers of folded lines', async () => { const {component, element, editor} = buildComponent() editor.foldBufferRow(1) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 3215db76053..ce37d329c3c 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -81,6 +81,7 @@ class TextEditorComponent { this.didMouseDownOnContent = this.didMouseDownOnContent.bind(this) this.lineTopIndex = new LineTopIndex() this.updateScheduled = false + this.suppressUpdates = false this.hasInitialMeasurements = false this.measurements = { lineHeight: 0, @@ -175,6 +176,7 @@ class TextEditorComponent { scheduleUpdate (nextUpdateOnlyBlinksCursors = false) { if (!this.visible) return + if (this.suppressUpdates) return this.nextUpdateOnlyBlinksCursors = this.nextUpdateOnlyBlinksCursors !== false && nextUpdateOnlyBlinksCursors === true @@ -219,6 +221,7 @@ class TextEditorComponent { this.measureBlockDecorations() this.measuredContent = false + this.updateModelSoftWrapColumn() this.updateSyncBeforeMeasuringContent() if (useScheduler === true) { const scheduler = etch.getScheduler() @@ -1945,6 +1948,13 @@ class TextEditorComponent { return marginInBaseCharacters * this.getBaseCharacterWidth() } + updateModelSoftWrapColumn () { + this.suppressUpdates = true + this.props.model.setEditorWidthInChars(this.getScrollContainerClientWidthInBaseCharacters()) + this.props.model.setEditorWidthInChars(this.getScrollContainerClientWidthInBaseCharacters()) + this.suppressUpdates = false + } + // This method exists because it existed in the previous implementation and some // package tests relied on it measureDimensions () { @@ -2013,7 +2023,6 @@ class TextEditorComponent { const clientContainerWidth = this.refs.clientContainer.offsetWidth if (clientContainerWidth !== this.measurements.clientContainerWidth) { this.measurements.clientContainerWidth = clientContainerWidth - this.props.model.setEditorWidthInChars(this.getScrollContainerWidth() / this.getBaseCharacterWidth()) return true } else { return false @@ -2436,6 +2445,10 @@ class TextEditorComponent { return Math.round(this.getLongestLineWidth() + this.getBaseCharacterWidth()) } + getScrollContainerClientWidthInBaseCharacters () { + return Math.floor(this.getScrollContainerClientWidth() / this.getBaseCharacterWidth()) + } + getGutterContainerWidth () { return this.measurements.gutterContainerWidth } From abfcfb3c9af134bd68cffd492c05242a23cad5d1 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 4 May 2017 15:26:14 -0600 Subject: [PATCH 335/403] Set `overflow: hidden` and `contain: layout paint` on lines --- static/text-editor.less | 2 ++ 1 file changed, 2 insertions(+) diff --git a/static/text-editor.less b/static/text-editor.less index 69c8dce4888..91010fb3e20 100644 --- a/static/text-editor.less +++ b/static/text-editor.less @@ -86,6 +86,8 @@ atom-text-editor { .line { white-space: pre; + overflow: hidden; + contain: layout paint; &.cursor-line .fold-marker::after { opacity: 1; From c00ad62a0eb002922682959b6d2f798d906c0568 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 4 May 2017 16:47:35 -0600 Subject: [PATCH 336/403] Pass mini attribute when creating new TextEditor from TextEditorElement This avoids content being shifted over due to rendering and measuring the gutter on element creation and then subsequently hiding it. --- spec/text-editor-element-spec.js | 8 ++++++++ src/text-editor-component.js | 4 +++- src/text-editor-element.js | 3 ++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/spec/text-editor-element-spec.js b/spec/text-editor-element-spec.js index 60b0fd708e7..cb778e3ed51 100644 --- a/spec/text-editor-element-spec.js +++ b/spec/text-editor-element-spec.js @@ -25,11 +25,19 @@ describe('TextEditorElement', () => { element.removeAttribute('mini') expect(element.getModel().isMini()).toBe(false) + expect(element.getComponent().getGutterContainerWidth()).toBe(0) element.setAttribute('mini', '') expect(element.getModel().isMini()).toBe(true) }) + it('sets the editor to mini if the model is accessed prior to attaching the element', () => { + const parent = document.createElement('div') + parent.innerHTML = '' + const element = parent.firstChild + expect(element.getModel().isMini()).toBe(true) + }) + it("honors the 'placeholder-text' attribute", () => { jasmineContent.innerHTML = "" const element = jasmineContent.firstChild diff --git a/src/text-editor-component.js b/src/text-editor-component.js index ce37d329c3c..bc32dd2065b 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -51,7 +51,9 @@ class TextEditorComponent { constructor (props) { this.props = props - if (!props.model) props.model = new TextEditor() + if (!props.model) { + props.model = new TextEditor({mini: props.mini}) + } this.props.model.component = this if (props.element) { diff --git a/src/text-editor-element.js b/src/text-editor-element.js index 0c8b50a626f..275b3b702f4 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -40,7 +40,6 @@ class TextEditorElement extends HTMLElement { attachedCallback () { this.getComponent().didAttach() this.emitter.emit('did-attach') - this.updateModelFromAttributes() } detachedCallback () { @@ -275,8 +274,10 @@ class TextEditorElement extends HTMLElement { if (!this.component) { this.component = new TextEditorComponent({ element: this, + mini: this.hasAttribute('mini'), updatedSynchronously: this.updatedSynchronously }) + this.updateModelFromAttributes() } return this.component From 1b1973db151e8cedc5b3bf54980940f7ec6a061d Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 4 May 2017 16:49:14 -0600 Subject: [PATCH 337/403] Rename method to match old implementation --- spec/text-editor-component-spec.js | 16 ++++++++-------- src/text-editor-component.js | 2 +- src/text-editor-element.js | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index f4abd9544af..3333c1d1d8f 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -3091,7 +3091,7 @@ describe('TextEditorComponent', () => { }) }) - describe('pixelPositionForScreenPositionSync(point)', () => { + describe('pixelPositionForScreenPosition(point)', () => { it('returns the pixel position for the given point, regardless of whether or not it is currently on screen', async () => { const {component, element, editor} = buildComponent({rowsPerTile: 2, autoHeight: false}) await setEditorHeightInLines(component, 3) @@ -3101,19 +3101,19 @@ describe('TextEditorComponent', () => { const referenceContentRect = referenceComponent.refs.content.getBoundingClientRect() { - const {top, left} = component.pixelPositionForScreenPositionSync({row: 0, column: 0}) + const {top, left} = component.pixelPositionForScreenPosition({row: 0, column: 0}) expect(top).toBe(clientTopForLine(referenceComponent, 0) - referenceContentRect.top) expect(left).toBe(clientLeftForCharacter(referenceComponent, 0, 0) - referenceContentRect.left) } { - const {top, left} = component.pixelPositionForScreenPositionSync({row: 0, column: 5}) + const {top, left} = component.pixelPositionForScreenPosition({row: 0, column: 5}) expect(top).toBe(clientTopForLine(referenceComponent, 0) - referenceContentRect.top) expect(left).toBe(clientLeftForCharacter(referenceComponent, 0, 5) - referenceContentRect.left) } { - const {top, left} = component.pixelPositionForScreenPositionSync({row: 12, column: 1}) + const {top, left} = component.pixelPositionForScreenPosition({row: 12, column: 1}) expect(top).toBe(clientTopForLine(referenceComponent, 12) - referenceContentRect.top) expect(left).toBe(clientLeftForCharacter(referenceComponent, 12, 1) - referenceContentRect.left) } @@ -3128,28 +3128,28 @@ describe('TextEditorComponent', () => { const {component: referenceComponent} = buildComponent() { - const pixelPosition = referenceComponent.pixelPositionForScreenPositionSync({row: 0, column: 0}) + const pixelPosition = referenceComponent.pixelPositionForScreenPosition({row: 0, column: 0}) pixelPosition.top += component.getLineHeight() / 3 pixelPosition.left += component.getBaseCharacterWidth() / 3 expect(component.screenPositionForPixelPosition(pixelPosition)).toEqual([0, 0]) } { - const pixelPosition = referenceComponent.pixelPositionForScreenPositionSync({row: 0, column: 5}) + const pixelPosition = referenceComponent.pixelPositionForScreenPosition({row: 0, column: 5}) pixelPosition.top += component.getLineHeight() / 3 pixelPosition.left += component.getBaseCharacterWidth() / 3 expect(component.screenPositionForPixelPosition(pixelPosition)).toEqual([0, 5]) } { - const pixelPosition = referenceComponent.pixelPositionForScreenPositionSync({row: 5, column: 7}) + const pixelPosition = referenceComponent.pixelPositionForScreenPosition({row: 5, column: 7}) pixelPosition.top += component.getLineHeight() / 3 pixelPosition.left += component.getBaseCharacterWidth() / 3 expect(component.screenPositionForPixelPosition(pixelPosition)).toEqual([5, 7]) } { - const pixelPosition = referenceComponent.pixelPositionForScreenPositionSync({row: 12, column: 1}) + const pixelPosition = referenceComponent.pixelPositionForScreenPosition({row: 12, column: 1}) pixelPosition.top += component.getLineHeight() / 3 pixelPosition.left += component.getBaseCharacterWidth() / 3 expect(component.screenPositionForPixelPosition(pixelPosition)).toEqual([12, 1]) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index bc32dd2065b..51da032224e 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -165,7 +165,7 @@ class TextEditorComponent { this.scheduleUpdate() } - pixelPositionForScreenPositionSync ({row, column}) { + pixelPositionForScreenPosition ({row, column}) { const top = this.pixelPositionAfterBlocksForRow(row) let left = column === 0 ? 0 : this.pixelLeftForRowAndColumn(row, column) if (left == null) { diff --git a/src/text-editor-element.js b/src/text-editor-element.js index 275b3b702f4..d56c5596b19 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -225,7 +225,7 @@ class TextEditorElement extends HTMLElement { // pixel position. pixelPositionForBufferPosition (bufferPosition) { const screenPosition = this.getModel().screenPositionForBufferPosition(bufferPosition) - return this.getComponent().pixelPositionForScreenPositionSync(screenPosition) + return this.getComponent().pixelPositionForScreenPosition(screenPosition) } // Extended: Converts a screen position to a pixel position. @@ -240,7 +240,7 @@ class TextEditorElement extends HTMLElement { // pixel position. pixelPositionForScreenPosition (screenPosition) { screenPosition = this.getModel().clipScreenPosition(screenPosition) - return this.getComponent().pixelPositionForScreenPositionSync(screenPosition) + return this.getComponent().pixelPositionForScreenPosition(screenPosition) } screenPositionForPixelPosition (pixelPosition) { From c5c48094baceebba60bbdf3638bb9feb2f97a549 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 4 May 2017 20:14:19 -0600 Subject: [PATCH 338/403] Avoid requesting horizontal measurement when auto-scrolling vertically This was leaving a measurement request in the map that was getting picked up on the next frame. In some cases, the requested measurement row was not present, causing an exception. --- spec/text-editor-component-spec.js | 11 +++++++++-- src/text-editor-component.js | 22 +++++++++++----------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 3333c1d1d8f..614802edaff 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -826,9 +826,9 @@ describe('TextEditorComponent', () => { }) it('accounts for the presence of horizontal scrollbars that appear during the same frame as the autoscroll', async () => { - const {component, element, editor} = buildComponent() + const {component, element, editor} = buildComponent({autoHeight: false}) const {scrollContainer} = component.refs - element.style.height = component.getScrollHeight() + 'px' + element.style.height = component.getContentHeight() / 2 + 'px' element.style.width = component.getScrollWidth() + 'px' await component.getNextUpdatePromise() @@ -838,6 +838,13 @@ describe('TextEditorComponent', () => { expect(component.getScrollTop()).toBe(component.getScrollHeight() - component.getScrollContainerClientHeight()) expect(component.getScrollLeft()).toBe(component.getScrollWidth() - component.getScrollContainerClientWidth()) + + // Scrolling to the top should not throw an error. This failed + // previously due to horizontalPositionsToMeasure not being empty after + // autoscrolling vertically to account for the horizontal scrollbar. + spyOn(window, 'onerror') + await setScrollTop(component, 0) + expect(window.onerror).not.toHaveBeenCalled() }) }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 51da032224e..ae4db7f9d78 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -311,7 +311,12 @@ class TextEditorComponent { updateSyncBeforeMeasuringContent () { this.derivedDimensionsCache = {} - if (this.pendingAutoscroll) this.autoscrollVertically() + if (this.pendingAutoscroll) { + const {screenRange, options} = this.pendingAutoscroll + this.autoscrollVertically(screenRange, options) + this.requestHorizontalMeasurement(screenRange.start.row, screenRange.start.column) + this.requestHorizontalMeasurement(screenRange.end.row, screenRange.end.column) + } this.populateVisibleRowRange() this.queryScreenLinesToRender() this.queryLineNumbersToRender() @@ -339,9 +344,10 @@ class TextEditorComponent { if (this.pendingAutoscroll) { this.derivedDimensionsCache = {} - this.autoscrollHorizontally() + const {screenRange, options} = this.pendingAutoscroll + this.autoscrollHorizontally(screenRange, options) if (!wasHorizontalScrollbarVisible && this.isHorizontalScrollbarVisible()) { - this.autoscrollVertically() + this.autoscrollVertically(screenRange, options) } this.pendingAutoscroll = null } @@ -1860,16 +1866,11 @@ class TextEditorComponent { } } - autoscrollVertically () { - const {screenRange, options} = this.pendingAutoscroll - + autoscrollVertically (screenRange, options) { const screenRangeTop = this.pixelPositionAfterBlocksForRow(screenRange.start.row) const screenRangeBottom = this.pixelPositionAfterBlocksForRow(screenRange.end.row) + this.getLineHeight() const verticalScrollMargin = this.getVerticalAutoscrollMargin() - this.requestHorizontalMeasurement(screenRange.start.row, screenRange.start.column) - this.requestHorizontalMeasurement(screenRange.end.row, screenRange.end.column) - let desiredScrollTop, desiredScrollBottom if (options && options.center) { const desiredScrollCenter = (screenRangeTop + screenRangeBottom) / 2 @@ -1901,10 +1902,9 @@ class TextEditorComponent { return false } - autoscrollHorizontally () { + autoscrollHorizontally (screenRange, options) { const horizontalScrollMargin = this.getHorizontalAutoscrollMargin() - const {screenRange, options} = this.pendingAutoscroll const gutterContainerWidth = this.getGutterContainerWidth() let left = this.pixelLeftForRowAndColumn(screenRange.start.row, screenRange.start.column) + gutterContainerWidth let right = this.pixelLeftForRowAndColumn(screenRange.end.row, screenRange.end.column) + gutterContainerWidth From 0c7030c70b5d03063d4bfc6d13036fa662d470c5 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 4 May 2017 20:16:02 -0600 Subject: [PATCH 339/403] Only resolve update promise after final render phase --- src/text-editor-component.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index ae4db7f9d78..f2614188f3d 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -239,9 +239,6 @@ class TextEditorComponent { this.measuredContent = true this.updateSyncAfterMeasuringContent() } - - this.derivedDimensionsCache = {} - if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise() } measureBlockDecorations () { @@ -373,6 +370,9 @@ class TextEditorComponent { this.remeasureScrollbars = false etch.updateSync(this) } + + this.derivedDimensionsCache = {} + if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise() } render () { From bc34344d90f099c6e3c7397687f9d0e9a670c3c7 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 4 May 2017 20:27:09 -0600 Subject: [PATCH 340/403] Maintain the scroll position when changing font size --- spec/text-editor-component-spec.js | 20 ++++++++++++++++++++ src/text-editor-component.js | 12 ++++++++++++ 2 files changed, 32 insertions(+) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 614802edaff..3a55cf992c9 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -3034,6 +3034,26 @@ describe('TextEditorComponent', () => { verifyCursorPosition(component, cursorNode, 1, 29) }) + it('maintains the scrollTopRow and scrollLeftColumn when the font size changes', async () => { + const {component, element, editor} = buildComponent({rowsPerTile: 1, autoHeight: false}) + await setEditorHeightInLines(component, 3) + await setEditorWidthInCharacters(component, 20) + component.setScrollTopRow(4) + component.setScrollLeftColumn(10) + await component.getNextUpdatePromise() + + const initialFontSize = parseInt(getComputedStyle(element).fontSize) + element.style.fontSize = initialFontSize - 5 + 'px' + TextEditor.didUpdateStyles() + await component.getNextUpdatePromise() + expect(component.getScrollTopRow()).toBe(4) + + element.style.fontSize = initialFontSize + 5 + 'px' + TextEditor.didUpdateStyles() + await component.getNextUpdatePromise() + expect(component.getScrollTopRow()).toBe(4) + }) + it('gracefully handles the editor being hidden after a styling change', async () => { const {component, element, editor} = buildComponent({autoHeight: false}) element.style.fontSize = parseInt(getComputedStyle(element).fontSize) + 5 + 'px' diff --git a/src/text-editor-component.js b/src/text-editor-component.js index f2614188f3d..84ec0f4592e 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -215,8 +215,20 @@ class TextEditorComponent { } if (this.remeasureCharacterDimensions) { + const originalLineHeight = this.getLineHeight() + const originalBaseCharacterWidth = this.getBaseCharacterWidth() + const scrollTopRow = this.getScrollTopRow() + const scrollLeftColumn = this.getScrollLeftColumn() + this.measureCharacterDimensions() this.measureGutterDimensions() + + if (this.getLineHeight() !== originalLineHeight) { + this.setScrollTopRow(scrollTopRow) + } + if (this.getBaseCharacterWidth() !== originalBaseCharacterWidth) { + this.setScrollLeftColumn(scrollLeftColumn) + } this.remeasureCharacterDimensions = false } From c541d3941c7cc7acf145386e6e1cbe1ce842e216 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 5 May 2017 09:04:34 +0200 Subject: [PATCH 341/403] Fix remaining test failures in core --- spec/text-editor-spec.coffee | 46 ++++++++++----------- spec/tokenized-buffer-iterator-spec.js | 56 +++++++++++++------------- src/text-editor.coffee | 14 +++---- 3 files changed, 58 insertions(+), 58 deletions(-) diff --git a/spec/text-editor-spec.coffee b/spec/text-editor-spec.coffee index 99e8f497f14..cf6a7e30381 100644 --- a/spec/text-editor-spec.coffee +++ b/spec/text-editor-spec.coffee @@ -5418,8 +5418,8 @@ describe "TextEditor", -> tokens = editor.tokensForScreenRow(0) expect(tokens).toEqual [ - {text: '//', scopes: ['syntax--source.syntax--js', 'syntax--comment.syntax--line.syntax--double-slash.syntax--js', 'syntax--punctuation.syntax--definition.syntax--comment.syntax--js']}, - {text: ' http://github.com', scopes: ['syntax--source.syntax--js', 'syntax--comment.syntax--line.syntax--double-slash.syntax--js']} + {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, + {text: ' http://github.com', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} ] waitsForPromise -> @@ -5428,9 +5428,9 @@ describe "TextEditor", -> runs -> tokens = editor.tokensForScreenRow(0) expect(tokens).toEqual [ - {text: '//', scopes: ['syntax--source.syntax--js', 'syntax--comment.syntax--line.syntax--double-slash.syntax--js', 'syntax--punctuation.syntax--definition.syntax--comment.syntax--js']}, - {text: ' ', scopes: ['syntax--source.syntax--js', 'syntax--comment.syntax--line.syntax--double-slash.syntax--js']} - {text: 'http://github.com', scopes: ['syntax--source.syntax--js', 'syntax--comment.syntax--line.syntax--double-slash.syntax--js', 'syntax--markup.syntax--underline.syntax--link.syntax--http.syntax--hyperlink']} + {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, + {text: ' ', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} + {text: 'http://github.com', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--markup syntax--underline syntax--link syntax--http syntax--hyperlink']} ] describe "when the grammar is updated", -> @@ -5443,8 +5443,8 @@ describe "TextEditor", -> tokens = editor.tokensForScreenRow(0) expect(tokens).toEqual [ - {text: '//', scopes: ['syntax--source.syntax--js', 'syntax--comment.syntax--line.syntax--double-slash.syntax--js', 'syntax--punctuation.syntax--definition.syntax--comment.syntax--js']}, - {text: ' SELECT * FROM OCTOCATS', scopes: ['syntax--source.syntax--js', 'syntax--comment.syntax--line.syntax--double-slash.syntax--js']} + {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, + {text: ' SELECT * FROM OCTOCATS', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} ] waitsForPromise -> @@ -5453,8 +5453,8 @@ describe "TextEditor", -> runs -> tokens = editor.tokensForScreenRow(0) expect(tokens).toEqual [ - {text: '//', scopes: ['syntax--source.syntax--js', 'syntax--comment.syntax--line.syntax--double-slash.syntax--js', 'syntax--punctuation.syntax--definition.syntax--comment.syntax--js']}, - {text: ' SELECT * FROM OCTOCATS', scopes: ['syntax--source.syntax--js', 'syntax--comment.syntax--line.syntax--double-slash.syntax--js']} + {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, + {text: ' SELECT * FROM OCTOCATS', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} ] waitsForPromise -> @@ -5463,14 +5463,14 @@ describe "TextEditor", -> runs -> tokens = editor.tokensForScreenRow(0) expect(tokens).toEqual [ - {text: '//', scopes: ['syntax--source.syntax--js', 'syntax--comment.syntax--line.syntax--double-slash.syntax--js', 'syntax--punctuation.syntax--definition.syntax--comment.syntax--js']}, - {text: ' ', scopes: ['syntax--source.syntax--js', 'syntax--comment.syntax--line.syntax--double-slash.syntax--js']}, - {text: 'SELECT', scopes: ['syntax--source.syntax--js', 'syntax--comment.syntax--line.syntax--double-slash.syntax--js', 'syntax--keyword.syntax--other.syntax--DML.syntax--sql']}, - {text: ' ', scopes: ['syntax--source.syntax--js', 'syntax--comment.syntax--line.syntax--double-slash.syntax--js']}, - {text: '*', scopes: ['syntax--source.syntax--js', 'syntax--comment.syntax--line.syntax--double-slash.syntax--js', 'syntax--keyword.syntax--operator.syntax--star.syntax--sql']}, - {text: ' ', scopes: ['syntax--source.syntax--js', 'syntax--comment.syntax--line.syntax--double-slash.syntax--js']}, - {text: 'FROM', scopes: ['syntax--source.syntax--js', 'syntax--comment.syntax--line.syntax--double-slash.syntax--js', 'syntax--keyword.syntax--other.syntax--DML.syntax--sql']}, - {text: ' OCTOCATS', scopes: ['syntax--source.syntax--js', 'syntax--comment.syntax--line.syntax--double-slash.syntax--js']} + {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, + {text: ' ', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']}, + {text: 'SELECT', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--keyword syntax--other syntax--DML syntax--sql']}, + {text: ' ', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']}, + {text: '*', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--keyword syntax--operator syntax--star syntax--sql']}, + {text: ' ', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']}, + {text: 'FROM', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--keyword syntax--other syntax--DML syntax--sql']}, + {text: ' OCTOCATS', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} ] describe ".normalizeTabsInBufferRange()", -> @@ -5853,20 +5853,20 @@ describe "TextEditor", -> editor.update({showIndentGuide: false}) expect(editor.tokensForScreenRow(0)).toEqual [ - {text: ' ', scopes: ['syntax--source.syntax--js', 'leading-whitespace']}, - {text: 'foo', scopes: ['syntax--source.syntax--js']} + {text: ' ', scopes: ['syntax--source syntax--js', 'leading-whitespace']}, + {text: 'foo', scopes: ['syntax--source syntax--js']} ] editor.update({showIndentGuide: true}) expect(editor.tokensForScreenRow(0)).toEqual [ - {text: ' ', scopes: ['syntax--source.syntax--js', 'leading-whitespace indent-guide']}, - {text: 'foo', scopes: ['syntax--source.syntax--js']} + {text: ' ', scopes: ['syntax--source syntax--js', 'leading-whitespace indent-guide']}, + {text: 'foo', scopes: ['syntax--source syntax--js']} ] editor.setMini(true) expect(editor.tokensForScreenRow(0)).toEqual [ - {text: ' ', scopes: ['syntax--source.syntax--js', 'leading-whitespace']}, - {text: 'foo', scopes: ['syntax--source.syntax--js']} + {text: ' ', scopes: ['syntax--source syntax--js', 'leading-whitespace']}, + {text: 'foo', scopes: ['syntax--source syntax--js']} ] describe "when the editor is constructed with the grammar option set", -> diff --git a/spec/tokenized-buffer-iterator-spec.js b/spec/tokenized-buffer-iterator-spec.js index e1440c675fa..14e656a8fc7 100644 --- a/spec/tokenized-buffer-iterator-spec.js +++ b/spec/tokenized-buffer-iterator-spec.js @@ -25,40 +25,40 @@ describe('TokenizedBufferIterator', () => { expect(iterator.seek(Point(0, 0))).toEqual([]) expect(iterator.getPosition()).toEqual(Point(0, 0)) expect(iterator.getCloseScopeIds()).toEqual([]) - expect(iterator.getOpenScopeIds()).toEqual([1]) + expect(iterator.getOpenScopeIds()).toEqual([257]) iterator.moveToSuccessor() - expect(iterator.getCloseScopeIds()).toEqual([1]) - expect(iterator.getOpenScopeIds()).toEqual([3]) + expect(iterator.getCloseScopeIds()).toEqual([257]) + expect(iterator.getOpenScopeIds()).toEqual([259]) - expect(iterator.seek(Point(0, 1))).toEqual([5]) + expect(iterator.seek(Point(0, 1))).toEqual([261]) expect(iterator.getPosition()).toEqual(Point(0, 3)) expect(iterator.getCloseScopeIds()).toEqual([]) - expect(iterator.getOpenScopeIds()).toEqual([3]) + expect(iterator.getOpenScopeIds()).toEqual([259]) iterator.moveToSuccessor() expect(iterator.getPosition()).toEqual(Point(0, 3)) - expect(iterator.getCloseScopeIds()).toEqual([3, 5]) - expect(iterator.getOpenScopeIds()).toEqual([5]) + expect(iterator.getCloseScopeIds()).toEqual([259, 261]) + expect(iterator.getOpenScopeIds()).toEqual([261]) - expect(iterator.seek(Point(0, 3))).toEqual([5]) + expect(iterator.seek(Point(0, 3))).toEqual([261]) expect(iterator.getPosition()).toEqual(Point(0, 3)) expect(iterator.getCloseScopeIds()).toEqual([]) - expect(iterator.getOpenScopeIds()).toEqual([3]) + expect(iterator.getOpenScopeIds()).toEqual([259]) iterator.moveToSuccessor() expect(iterator.getPosition()).toEqual(Point(0, 3)) - expect(iterator.getCloseScopeIds()).toEqual([3, 5]) - expect(iterator.getOpenScopeIds()).toEqual([5]) + expect(iterator.getCloseScopeIds()).toEqual([259, 261]) + expect(iterator.getOpenScopeIds()).toEqual([261]) iterator.moveToSuccessor() expect(iterator.getPosition()).toEqual(Point(0, 7)) - expect(iterator.getCloseScopeIds()).toEqual([5]) - expect(iterator.getOpenScopeIds()).toEqual([3]) + expect(iterator.getCloseScopeIds()).toEqual([261]) + expect(iterator.getOpenScopeIds()).toEqual([259]) iterator.moveToSuccessor() expect(iterator.getPosition()).toEqual(Point(0, 7)) - expect(iterator.getCloseScopeIds()).toEqual([3]) + expect(iterator.getCloseScopeIds()).toEqual([259]) expect(iterator.getOpenScopeIds()).toEqual([]) iterator.moveToSuccessor() @@ -66,14 +66,14 @@ describe('TokenizedBufferIterator', () => { expect(iterator.getCloseScopeIds()).toEqual([]) expect(iterator.getOpenScopeIds()).toEqual([]) - expect(iterator.seek(Point(0, 5))).toEqual([5]) + expect(iterator.seek(Point(0, 5))).toEqual([261]) expect(iterator.getPosition()).toEqual(Point(0, 7)) - expect(iterator.getCloseScopeIds()).toEqual([5]) - expect(iterator.getOpenScopeIds()).toEqual([3]) + expect(iterator.getCloseScopeIds()).toEqual([261]) + expect(iterator.getOpenScopeIds()).toEqual([259]) iterator.moveToSuccessor() expect(iterator.getPosition()).toEqual(Point(0, 7)) - expect(iterator.getCloseScopeIds()).toEqual([3]) + expect(iterator.getCloseScopeIds()).toEqual([259]) expect(iterator.getOpenScopeIds()).toEqual([]) }) }) @@ -95,15 +95,15 @@ describe('TokenizedBufferIterator', () => { iterator.seek(Point(0, 0)) expect(iterator.getPosition()).toEqual(Point(0, 0)) expect(iterator.getCloseScopeIds()).toEqual([]) - expect(iterator.getOpenScopeIds()).toEqual([1]) + expect(iterator.getOpenScopeIds()).toEqual([257]) iterator.moveToSuccessor() expect(iterator.getPosition()).toEqual(Point(0, 0)) - expect(iterator.getCloseScopeIds()).toEqual([1]) - expect(iterator.getOpenScopeIds()).toEqual([1]) + expect(iterator.getCloseScopeIds()).toEqual([257]) + expect(iterator.getOpenScopeIds()).toEqual([257]) iterator.moveToSuccessor() - expect(iterator.getCloseScopeIds()).toEqual([1]) + expect(iterator.getCloseScopeIds()).toEqual([257]) expect(iterator.getOpenScopeIds()).toEqual([]) }) @@ -137,26 +137,26 @@ describe('TokenizedBufferIterator', () => { iterator.seek(Point(0, 0)) expect(iterator.getPosition()).toEqual(Point(0, 0)) expect(iterator.getCloseScopeIds()).toEqual([]) - expect(iterator.getOpenScopeIds()).toEqual([1]) + expect(iterator.getOpenScopeIds()).toEqual([257]) iterator.moveToSuccessor() expect(iterator.getPosition()).toEqual(Point(0, 3)) - expect(iterator.getCloseScopeIds()).toEqual([1]) - expect(iterator.getOpenScopeIds()).toEqual([3]) + expect(iterator.getCloseScopeIds()).toEqual([257]) + expect(iterator.getOpenScopeIds()).toEqual([259]) iterator.moveToSuccessor() expect(iterator.getPosition()).toEqual(Point(0, 3)) - expect(iterator.getCloseScopeIds()).toEqual([3]) + expect(iterator.getCloseScopeIds()).toEqual([259]) expect(iterator.getOpenScopeIds()).toEqual([]) iterator.moveToSuccessor() expect(iterator.getPosition()).toEqual(Point(1, 0)) expect(iterator.getCloseScopeIds()).toEqual([]) - expect(iterator.getOpenScopeIds()).toEqual([1]) + expect(iterator.getOpenScopeIds()).toEqual([257]) iterator.moveToSuccessor() expect(iterator.getPosition()).toEqual(Point(2, 0)) - expect(iterator.getCloseScopeIds()).toEqual([1]) + expect(iterator.getCloseScopeIds()).toEqual([257]) expect(iterator.getOpenScopeIds()).toEqual([]) }) }) diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 248d87d8153..e5a64c3718e 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -1017,18 +1017,18 @@ class TextEditor extends Model tokens = [] lineTextIndex = 0 currentTokenScopes = [] - {lineText, tagCodes} = @screenLineForScreenRow(screenRow) - for tagCode in tagCodes - if @displayLayer.isOpenTagCode(tagCode) - currentTokenScopes.push(@displayLayer.tagForCode(tagCode)) - else if @displayLayer.isCloseTagCode(tagCode) + {lineText, tags} = @screenLineForScreenRow(screenRow) + for tag in tags + if @displayLayer.isOpenTag(tag) + currentTokenScopes.push(@displayLayer.classNameForTag(tag)) + else if @displayLayer.isCloseTag(tag) currentTokenScopes.pop() else tokens.push({ - text: lineText.substr(lineTextIndex, tagCode) + text: lineText.substr(lineTextIndex, tag) scopes: currentTokenScopes.slice() }) - lineTextIndex += tagCode + lineTextIndex += tag tokens screenLineForScreenRow: (screenRow) -> From 97d2d7fb8b69566eab0b26d3971665aced9665ec Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 5 May 2017 09:07:17 +0200 Subject: [PATCH 342/403] Fix remaining linting warnings --- src/text-editor-component.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 84ec0f4592e..edd534559f0 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -3868,10 +3868,10 @@ function objectsEqual (a, b) { if (!a && b) return false if (a && !b) return false if (a && b) { - for (key in a) { + for (const key in a) { if (a[key] !== b[key]) return false } - for (key in b) { + for (const key in b) { if (a[key] !== b[key]) return false } } From f7b79b477a3119c5a15e0e03da23f6fbb7dea1d0 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 5 May 2017 09:27:17 +0200 Subject: [PATCH 343/403] Update class list even when the editor is not attached --- spec/text-editor-component-spec.js | 15 ++++++++++++--- src/text-editor-component.js | 1 + 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 3a55cf992c9..d68ba5c0011 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -599,9 +599,18 @@ describe('TextEditorComponent', () => { }) describe('mini editors', () => { - it('adds the mini attribute', () => { - const {element, editor} = buildComponent({mini: true}) - expect(element.hasAttribute('mini')).toBe(true) + it('adds the mini attribute and class even when the element is not attached', () => { + { + const {element, editor} = buildComponent({mini: true}) + expect(element.hasAttribute('mini')).toBe(true) + expect(element.classList.contains('mini')).toBe(true) + } + + { + const {element, editor} = buildComponent({mini: true, attach: false}) + expect(element.hasAttribute('mini')).toBe(true) + expect(element.classList.contains('mini')).toBe(true) + } }) it('does not render the gutter container', () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index edd534559f0..c339cdf9608 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -157,6 +157,7 @@ class TextEditorComponent { this.queryGuttersToRender() this.queryMaxLineNumberDigits() this.observeBlockDecorations() + this.updateClassList() etch.updateSync(this) } From 2855e0128914f3674491ca0c27c5a17e1c9ed0f1 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 5 May 2017 11:22:01 +0200 Subject: [PATCH 344/403] Don't create empty nodes when a text decoration ends next to a text tag This was causing problems in measurements because in that code path we assume that text nodes are never empty. This commit also adds a test verifying this invariant when a text decoration ending right after a text tag is added. --- spec/text-editor-component-spec.js | 11 ++++++++++- src/text-editor-component.js | 8 +++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index d68ba5c0011..b5d6d98c7c6 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1983,7 +1983,6 @@ describe('TextEditorComponent', () => { const marker1 = markerLayer.markBufferRange([[0, 2], [2, 7]]) const marker2 = markerLayer.markBufferRange([[0, 2], [3, 8]]) const marker3 = markerLayer.markBufferRange([[1, 13], [2, 7]]) - editor.decorateMarker(marker1, {type: 'text', class: 'a', style: {color: 'red'}}) editor.decorateMarker(marker2, {type: 'text', class: 'b', style: {color: 'blue'}}) editor.decorateMarker(marker3, {type: 'text', class: 'c', style: {color: 'green'}}) @@ -2060,6 +2059,16 @@ describe('TextEditorComponent', () => { } }) + it('does not create empty text nodes when a text decoration ends right after a text tag', async () => { + const {component, element, editor} = buildComponent() + const marker = editor.markBufferRange([[0, 8], [0, 29]]) + editor.decorateMarker(marker, {type: 'text', class: 'a'}) + await component.getNextUpdatePromise() + for (const textNode of textNodesForScreenRow(component, 0)) { + expect(textNode.textContent).not.toBe('') + } + }) + function textContentOnRowMatchingSelector (component, row, selector) { return Array.from(lineNodeForScreenRow(component, row).querySelectorAll(selector)) .map((span) => span.textContent) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index c339cdf9608..fcc35bc5590 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -3639,9 +3639,11 @@ class LineComponent { nextDecoration = textDecorations[++decorationIndex] } - const text = lineText.substring(column, nextTokenColumn) - this.appendTextNode(textNodes, openScopeNode, text, activeClassName, activeStyle) - column = nextTokenColumn + if (column < nextTokenColumn) { + const text = lineText.substring(column, nextTokenColumn) + this.appendTextNode(textNodes, openScopeNode, text, activeClassName, activeStyle) + column = nextTokenColumn + } } } } From b9783b125ee590b49e63eee300606b8b1a3bf00c Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 5 May 2017 14:18:16 +0200 Subject: [PATCH 345/403] Don't 'contain: paint' line elements This fixes https://github.com/atom/atom/pull/13880#issuecomment-296623782 once again. --- static/text-editor.less | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/text-editor.less b/static/text-editor.less index 91010fb3e20..06518ac0fd6 100644 --- a/static/text-editor.less +++ b/static/text-editor.less @@ -87,7 +87,7 @@ atom-text-editor { .line { white-space: pre; overflow: hidden; - contain: layout paint; + contain: layout; &.cursor-line .fold-marker::after { opacity: 1; From 15f25a745ae7e56b50c9c2075291228f6a8615dd Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 5 May 2017 18:33:33 +0200 Subject: [PATCH 346/403] Update width of content when approximate longest screen row changes --- spec/text-editor-component-spec.js | 33 ++++++++++++++++++++++++------ src/text-editor.coffee | 4 ++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index b5d6d98c7c6..e13086961df 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -4,6 +4,7 @@ const TextEditorComponent = require('../src/text-editor-component') const TextEditorElement = require('../src/text-editor-element') const TextEditor = require('../src/text-editor') const TextBuffer = require('text-buffer') +const {Point} = TextBuffer const fs = require('fs') const path = require('path') const Grim = require('grim') @@ -83,14 +84,34 @@ describe('TextEditorComponent', () => { ]) }) - it('bases the width of the lines div on the width of the longest initially-visible screen line', () => { - const {component, element, editor} = buildComponent({rowsPerTile: 2, height: 20}) + it('bases the width of the lines div on the width of the longest initially-visible screen line', async () => { + const {component, element, editor} = buildComponent({rowsPerTile: 2, height: 20, width: 100}) - expect(editor.getApproximateLongestScreenRow()).toBe(3) - const expectedWidth = element.querySelectorAll('.line')[3].offsetWidth - expect(element.querySelector('.lines').style.width).toBe(expectedWidth + 'px') + { + expect(editor.getApproximateLongestScreenRow()).toBe(3) + const expectedWidth = Math.round( + component.pixelPositionForScreenPosition(Point(3, Infinity)).left + + component.getBaseCharacterWidth() + ) + expect(element.querySelector('.lines').style.width).toBe(expectedWidth + 'px') + } - // TODO: Confirm that we'll update this value as indexing proceeds + { + // Get the next update promise synchronously here to ensure we don't + // miss the update while polling the condition. + const nextUpdatePromise = component.getNextUpdatePromise() + await conditionPromise(() => editor.getApproximateLongestScreenRow() === 6) + await nextUpdatePromise + + // Capture the width first, then update the DOM so we can measure the + // longest line. + const actualWidth = element.querySelector('.lines').style.width + const expectedWidth = Math.round( + component.pixelPositionForScreenPosition(Point(6, Infinity)).left + + component.getBaseCharacterWidth() + ) + expect(actualWidth).toBe(expectedWidth + 'px') + } }) it('honors the scrollPastEnd option by adding empty space equivalent to the clientHeight to the end of the content area', async () => { diff --git a/src/text-editor.coffee b/src/text-editor.coffee index e5a64c3718e..a7d321dd014 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -255,12 +255,16 @@ class TextEditor extends Model ] doBackgroundWork: (deadline) => + previousLongestRow = @getApproximateLongestScreenRow() if @displayLayer.doBackgroundWork(deadline) @presenter?.updateVerticalDimensions() @backgroundWorkHandle = requestIdleCallback(@doBackgroundWork) else @backgroundWorkHandle = null + if @getApproximateLongestScreenRow() isnt previousLongestRow + @component?.scheduleUpdate() + update: (params) -> displayLayerParams = {} From edf1b7fb74746826ca514f5a9aa2728aaf563052 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 5 May 2017 11:30:14 -0600 Subject: [PATCH 347/403] Remove dead code --- src/text-editor.coffee | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/text-editor.coffee b/src/text-editor.coffee index a7d321dd014..21921b4c43d 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -257,7 +257,6 @@ class TextEditor extends Model doBackgroundWork: (deadline) => previousLongestRow = @getApproximateLongestScreenRow() if @displayLayer.doBackgroundWork(deadline) - @presenter?.updateVerticalDimensions() @backgroundWorkHandle = requestIdleCallback(@doBackgroundWork) else @backgroundWorkHandle = null @@ -358,7 +357,6 @@ class TextEditor extends Model when 'showLineNumbers' if value isnt @showLineNumbers @showLineNumbers = value - @presenter?.didChangeShowLineNumbers() when 'showInvisibles' if value isnt @showInvisibles @@ -388,12 +386,10 @@ class TextEditor extends Model when 'autoHeight' if value isnt @autoHeight @autoHeight = value - @presenter?.setAutoHeight(@autoHeight) when 'autoWidth' if value isnt @autoWidth @autoWidth = value - @presenter?.didChangeAutoWidth() when 'showCursorOnSelection' if value isnt @showCursorOnSelection @@ -483,7 +479,6 @@ class TextEditor extends Model @emitter.emit 'did-destroy' @emitter.clear() @editorElement = null - @presenter = null ### Section: Event Subscription From df4116d4aa55635dedf18d87dc7c22a224183d4d Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 5 May 2017 18:18:30 +0200 Subject: [PATCH 348/403] Fix clicking past the content height Signed-off-by: Nathan Sobo --- spec/text-editor-component-spec.js | 10 ++++++++++ src/text-editor-component.js | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index e13086961df..006f2859ef6 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -114,6 +114,16 @@ describe('TextEditorComponent', () => { } }) + it('makes the content at least as tall as the scroll container client height', async () => { + const {component, element, editor} = buildComponent({text: 'a', height: 100}) + expect(component.refs.content.offsetHeight).toBe(100) + + editor.setText('a\n'.repeat(30)) + await component.getNextUpdatePromise() + expect(component.refs.content.offsetHeight).toBeGreaterThan(100) + expect(component.refs.content.offsetHeight).toBe(component.getContentHeight()) + }) + it('honors the scrollPastEnd option by adding empty space equivalent to the clientHeight to the end of the content area', async () => { const {component, element, editor} = buildComponent({autoHeight: false, autoWidth: false}) const {scrollContainer} = component.refs diff --git a/src/text-editor-component.js b/src/text-editor-component.js index fcc35bc5590..87f66875a2a 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2435,8 +2435,10 @@ class TextEditorComponent { 3 * this.getLineHeight(), this.getScrollContainerClientHeight() - (3 * this.getLineHeight()) ) - } else { + } else if (this.props.model.getAutoHeight()) { return this.getContentHeight() + } else { + return Math.max(this.getContentHeight(), this.getScrollContainerClientHeight()) } } From a7f658a40f4b62b0ac74b4d1b1d512132a918ed6 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 5 May 2017 19:47:23 +0200 Subject: [PATCH 349/403] Move cursors within a transaction to batch marker layer update events Signed-off-by: Nathan Sobo --- src/text-editor.coffee | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 21921b4c43d..df3f4a9b531 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -2341,8 +2341,9 @@ class TextEditor extends Model cursor moveCursors: (fn) -> - fn(cursor) for cursor in @getCursors() - @mergeCursors() + @transact => + fn(cursor) for cursor in @getCursors() + @mergeCursors() cursorMoved: (event) -> @emitter.emit 'did-change-cursor-position', event From 245f294cc3bb91ed8701b2f4028af94cac1f4101 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 5 May 2017 19:55:57 +0200 Subject: [PATCH 350/403] Call `editor.setEditorWidthInChars` only when the value changed Signed-off-by: Nathan Sobo --- src/text-editor-component.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 87f66875a2a..6d3ca9cc5fc 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1964,10 +1964,15 @@ class TextEditorComponent { } updateModelSoftWrapColumn () { - this.suppressUpdates = true - this.props.model.setEditorWidthInChars(this.getScrollContainerClientWidthInBaseCharacters()) - this.props.model.setEditorWidthInChars(this.getScrollContainerClientWidthInBaseCharacters()) - this.suppressUpdates = false + const {model} = this.props + const newEditorWidthInChars = this.getScrollContainerClientWidthInBaseCharacters() + if (newEditorWidthInChars !== model.getEditorWidthInChars()) { + this.suppressUpdates = true + this.props.model.setEditorWidthInChars(newEditorWidthInChars) + // Wrapping may cause a vertical scrollbar to appear, which will change the width again. + this.props.model.setEditorWidthInChars(this.getScrollContainerClientWidthInBaseCharacters()) + this.suppressUpdates = false + } } // This method exists because it existed in the previous implementation and some From f76e850aa5e3c5c7883778002a30e2324bfd64a3 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 8 May 2017 10:58:33 -0600 Subject: [PATCH 351/403] Fix rendering artifacts when resizing with soft wraps Previously, we were accidentally depending on the state of the display layer when forcing it to update its index. This caused us to not index enough content to cover the visibile area, which meant we weren't querying enough lines to fill the screen in some situations. --- spec/text-editor-component-spec.js | 11 +++++++++++ src/text-editor-component.js | 8 +++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 006f2859ef6..2fc544fc24d 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -457,6 +457,17 @@ describe('TextEditorComponent', () => { expect(editor.lineLengthForScreenRow(0)).toBe(20) }) + it('correctly forces the display layer to index visible rows when resizing (regression)', async () => { + const text = 'a'.repeat(30) + '\n' + 'b'.repeat(1000) + const {component, element, editor} = buildComponent({height: 300, width: 800, attach: false, text}) + editor.setSoftWrapped(true) + jasmine.attachToDOM(element) + + element.style.width = 200 + 'px' + await component.getNextUpdatePromise() + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(24) + }) + it('decorates the line numbers of folded lines', async () => { const {component, element, editor} = buildComponent() editor.foldBufferRow(1) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 6d3ca9cc5fc..c533ef67dd4 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -236,7 +236,6 @@ class TextEditorComponent { this.measureBlockDecorations() this.measuredContent = false - this.updateModelSoftWrapColumn() this.updateSyncBeforeMeasuringContent() if (useScheduler === true) { const scheduler = etch.getScheduler() @@ -321,6 +320,7 @@ class TextEditorComponent { updateSyncBeforeMeasuringContent () { this.derivedDimensionsCache = {} + this.updateModelSoftWrapColumn() if (this.pendingAutoscroll) { const {screenRange, options} = this.pendingAutoscroll this.autoscrollVertically(screenRange, options) @@ -2668,8 +2668,10 @@ class TextEditorComponent { // Ensure the spatial index is populated with rows that are currently // visible so we *at least* get the longest row in the visible range. populateVisibleRowRange () { - const endRow = this.getRenderedStartRow() + this.getVisibleTileCount() * this.getRowsPerTile() - this.props.model.displayLayer.populateSpatialIndexIfNeeded(Infinity, endRow) + const lastPossibleVisibleRow = this.rowForPixelPosition(this.getScrollBottom()) + const maxPossibleVisibleTileCount = Math.floor((lastPossibleVisibleRow - this.getFirstVisibleRow()) / this.getRowsPerTile()) + 2 + const lastPossibleRenderedRow = this.getRenderedStartRow() + maxPossibleVisibleTileCount * this.getRowsPerTile() + this.props.model.displayLayer.populateSpatialIndexIfNeeded(Infinity, lastPossibleRenderedRow) } getNextUpdatePromise () { From 681a1cb015fb269dcd47cfca5c70913d8e179bd2 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 8 May 2017 16:03:46 -0600 Subject: [PATCH 352/403] :arrow_up: text-buffer --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f907162d50e..60c2eecc82b 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "sinon": "1.17.4", "source-map-support": "^0.3.2", "temp": "^0.8.3", - "text-buffer": "12.1.0", + "text-buffer": "12.1.1", "typescript-simple": "1.0.0", "underscore-plus": "^1.6.6", "winreg": "^1.2.1", From 1c8847cb4f4ce46f3639471197e8e552999fd19b Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 8 May 2017 15:49:51 -0700 Subject: [PATCH 353/403] :racehorse: Ensure rendered tile count is stable when scrolling Signed-off-by: Nathan Sobo --- spec/text-editor-component-spec.js | 16 ++++++++++++++-- src/text-editor-component.js | 3 ++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 2fc544fc24d..9ff0ca14c71 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -174,6 +174,18 @@ describe('TextEditorComponent', () => { } }) + it('keeps the number of tiles stable when the visible line count changes during vertical scrolling', async () => { + const {component, element, editor} = buildComponent({rowsPerTile: 3, autoHeight: false}) + await setEditorHeightInLines(component, 5.5) + expect(component.refs.lineTiles.children.length).toBe(3) + + await setScrollTop(component, 0.5 * component.getLineHeight()) + expect(component.refs.lineTiles.children.length).toBe(3) + + await setScrollTop(component, 1 * component.getLineHeight()) + expect(component.refs.lineTiles.children.length).toBe(3) + }) + it('renders dummy vertical and horizontal scrollbars when content overflows', async () => { const {component, element, editor} = buildComponent({height: 100, width: 100}) const verticalScrollbar = component.refs.verticalScrollbar.element @@ -1916,7 +1928,7 @@ describe('TextEditorComponent', () => { // make the editor taller and wider and the same time, ensuring the number // of rendered lines is correct. - setEditorHeightInLines(component, 10) + setEditorHeightInLines(component, 13) await setEditorWidthInCharacters(component, 50) expect(component.getRenderedStartRow()).toBe(0) expect(component.getRenderedEndRow()).toBe(9) @@ -2062,7 +2074,7 @@ describe('TextEditorComponent', () => { it('correctly handles text decorations starting before the first rendered row and/or ending after the last rendered row', async () => { const {component, element, editor} = buildComponent({autoHeight: false, rowsPerTile: 1}) - element.style.height = 3 * component.getLineHeight() + 'px' + element.style.height = 4 * component.getLineHeight() + 'px' await component.getNextUpdatePromise() await setScrollTop(component, 4 * component.getLineHeight()) expect(component.getRenderedStartRow()).toBe(4) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index c533ef67dd4..3fee245edf8 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2555,7 +2555,8 @@ class TextEditorComponent { getVisibleTileCount () { if (this.derivedDimensionsCache.visibleTileCount == null) { - this.derivedDimensionsCache.visibleTileCount = Math.floor((this.getLastVisibleRow() - this.getFirstVisibleRow()) / this.getRowsPerTile()) + 2 + const visibleRowCount = this.getLastVisibleRow() - this.getFirstVisibleRow() + this.derivedDimensionsCache.visibleTileCount = Math.ceil(visibleRowCount / this.getRowsPerTile()) + 1 } return this.derivedDimensionsCache.visibleTileCount From 5be2db645a6b2f0203677f9d2e5db31eab8c6410 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 8 May 2017 15:28:55 +0200 Subject: [PATCH 354/403] Delete more dead code --- src/tiled-component.coffee | 51 -------------------------------------- 1 file changed, 51 deletions(-) delete mode 100644 src/tiled-component.coffee diff --git a/src/tiled-component.coffee b/src/tiled-component.coffee deleted file mode 100644 index 37de27e9b71..00000000000 --- a/src/tiled-component.coffee +++ /dev/null @@ -1,51 +0,0 @@ -module.exports = -class TiledComponent - updateSync: (state) -> - @newState = @getNewState(state) - @oldState ?= @buildEmptyState() - - @beforeUpdateSync?(state) - - @removeTileNodes() if @shouldRecreateAllTilesOnUpdate?() - @updateTileNodes() - - @afterUpdateSync?(state) - - removeTileNodes: -> - @removeTileNode(tileRow) for tileRow of @oldState.tiles - return - - removeTileNode: (tileRow) -> - @componentsByTileId[tileRow].destroy() - delete @componentsByTileId[tileRow] - delete @oldState.tiles[tileRow] - - updateTileNodes: -> - @componentsByTileId ?= {} - - for tileRow of @oldState.tiles - unless @newState.tiles.hasOwnProperty(tileRow) - @removeTileNode(tileRow) - - for tileRow, tileState of @newState.tiles - if @oldState.tiles.hasOwnProperty(tileRow) - component = @componentsByTileId[tileRow] - else - component = @componentsByTileId[tileRow] = @buildComponentForTile(tileRow) - - @getTilesNode().appendChild(component.getDomNode()) - @oldState.tiles[tileRow] = Object.assign({}, tileState) - - component.updateSync(@newState) - - return - - getComponentForTile: (tileRow) -> - @componentsByTileId[tileRow] - - getComponents: -> - for _, component of @componentsByTileId - component - - getTiles: -> - @getComponents().map((component) -> component.getDomNode()) From 7f3794b12e7b81def936dabce7a56f58b13678ee Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 9 May 2017 16:08:48 +0200 Subject: [PATCH 355/403] Don't populate more screen rows than necessary See 1c8847cb4f4ce46f3639471197e8e552999fd19b --- src/text-editor-component.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 3fee245edf8..b661f2bbd08 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2670,7 +2670,8 @@ class TextEditorComponent { // visible so we *at least* get the longest row in the visible range. populateVisibleRowRange () { const lastPossibleVisibleRow = this.rowForPixelPosition(this.getScrollBottom()) - const maxPossibleVisibleTileCount = Math.floor((lastPossibleVisibleRow - this.getFirstVisibleRow()) / this.getRowsPerTile()) + 2 + const maxPossibleVisibleRows = lastPossibleVisibleRow - this.getFirstVisibleRow() + const maxPossibleVisibleTileCount = Math.ceil(maxPossibleVisibleRows / this.getRowsPerTile()) + 1 const lastPossibleRenderedRow = this.getRenderedStartRow() + maxPossibleVisibleTileCount * this.getRowsPerTile() this.props.model.displayLayer.populateSpatialIndexIfNeeded(Infinity, lastPossibleRenderedRow) } From be2aaa0b224c7fe7fc69d87402f1949bf46f1ffb Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 9 May 2017 17:46:29 +0200 Subject: [PATCH 356/403] Use explicit state to recycle tiles instead of modulo scheme This avoids updating tiles unnecessarily when changing the number of rendered tiles. Signed-off-by: Nathan Sobo --- spec/text-editor-component-spec.js | 11 ++++- src/text-editor-component.js | 69 +++++++++++++++++++++--------- 2 files changed, 58 insertions(+), 22 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 9ff0ca14c71..78ab6a8e2c4 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -186,6 +186,15 @@ describe('TextEditorComponent', () => { expect(component.refs.lineTiles.children.length).toBe(3) }) + it('recycles tiles on resize', async () => { + const {component, element, editor} = buildComponent({rowsPerTile: 2, autoHeight: false}) + await setEditorHeightInLines(component, 7) + await setScrollTop(component, 3.5 * component.getLineHeight()) + const lineNode = lineNodeForScreenRow(component, 7) + await setEditorHeightInLines(component, 4) + expect(lineNodeForScreenRow(component, 7)).toBe(lineNode) + }) + it('renders dummy vertical and horizontal scrollbars when content overflows', async () => { const {component, element, editor} = buildComponent({height: 100, width: 100}) const verticalScrollbar = component.refs.verticalScrollbar.element @@ -3425,7 +3434,7 @@ function clientPositionForCharacter (component, row, column) { function lineNumberNodeForScreenRow (component, row) { const gutterElement = component.refs.gutterContainer.refs.lineNumberGutter.element const tileStartRow = component.tileStartRowForRow(row) - const tileIndex = component.tileIndexForTileStartRow(tileStartRow) + const tileIndex = component.renderedTileStartRows.indexOf(tileStartRow) return gutterElement.children[tileIndex + 1].children[row - tileStartRow] } diff --git a/src/text-editor-component.js b/src/text-editor-component.js index b661f2bbd08..da62af98cd1 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -127,6 +127,9 @@ class TextEditorComponent { this.remeasureGutterDimensions = false this.guttersToRender = [this.props.model.getLineNumberGutter()] this.guttersVisibility = [this.guttersToRender[0].visible] + this.idsByTileStartRow = new Map() + this.nextTileId = 0 + this.renderedTileStartRows = [] this.lineNumbersToRender = { maxDigits: 2, bufferRows: [], @@ -328,6 +331,7 @@ class TextEditorComponent { this.requestHorizontalMeasurement(screenRange.end.row, screenRange.end.column) } this.populateVisibleRowRange() + this.populateVisibleTiles() this.queryScreenLinesToRender() this.queryLineNumbersToRender() this.queryGuttersToRender() @@ -551,15 +555,15 @@ class TextEditorComponent { const tileWidth = this.getScrollWidth() const displayLayer = this.props.model.displayLayer - const tileNodes = new Array(this.getRenderedTileCount()) + const tileNodes = new Array(this.renderedTileStartRows.length) - for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow = tileStartRow + rowsPerTile) { + for (let i = 0; i < this.renderedTileStartRows.length; i++) { + const tileStartRow = this.renderedTileStartRows[i] const tileEndRow = Math.min(endRow, tileStartRow + rowsPerTile) const tileHeight = this.pixelPositionBeforeBlocksForRow(tileEndRow) - this.pixelPositionBeforeBlocksForRow(tileStartRow) - const tileIndex = this.tileIndexForTileStartRow(tileStartRow) - tileNodes[tileIndex] = $(LinesTileComponent, { - key: tileIndex, + tileNodes[i] = $(LinesTileComponent, { + key: this.idsByTileStartRow.get(tileStartRow), measuredContent: this.measuredContent, height: tileHeight, width: tileWidth, @@ -2495,10 +2499,6 @@ class TextEditorComponent { return row - (row % this.getRowsPerTile()) } - tileIndexForTileStartRow (startRow) { - return (startRow / this.getRowsPerTile()) % this.getRenderedTileCount() - } - getRenderedStartRow () { if (this.derivedDimensionsCache.renderedStartRow == null) { this.derivedDimensionsCache.renderedStartRow = this.tileStartRowForRow(this.getFirstVisibleRow()) @@ -2676,6 +2676,35 @@ class TextEditorComponent { this.props.model.displayLayer.populateSpatialIndexIfNeeded(Infinity, lastPossibleRenderedRow) } + populateVisibleTiles () { + const startRow = this.getRenderedStartRow() + const endRow = this.getRenderedEndRow() + const freeTileIds = [] + for (let i = 0; i < this.renderedTileStartRows.length; i++) { + const tileStartRow = this.renderedTileStartRows[i] + if (tileStartRow < startRow || tileStartRow >= endRow) { + const tileId = this.idsByTileStartRow.get(tileStartRow) + freeTileIds.push(tileId) + this.idsByTileStartRow.delete(tileStartRow) + } + } + + const rowsPerTile = this.getRowsPerTile() + this.renderedTileStartRows.length = this.getRenderedTileCount() + for (let tileStartRow = startRow, i = 0; tileStartRow < endRow; tileStartRow = tileStartRow + rowsPerTile, i++) { + this.renderedTileStartRows[i] = tileStartRow + if (!this.idsByTileStartRow.has(tileStartRow)) { + if (freeTileIds.length > 0) { + this.idsByTileStartRow.set(tileStartRow, freeTileIds.shift()) + } else { + this.idsByTileStartRow.set(tileStartRow, this.nextTileId++) + } + } + } + + this.renderedTileStartRows.sort((a, b) => this.idsByTileStartRow.get(a) - this.idsByTileStartRow.get(b)) + } + getNextUpdatePromise () { if (!this.nextUpdatePromise) { this.nextUpdatePromise = new Promise((resolve) => { @@ -2914,18 +2943,17 @@ class LineNumberGutterComponent { let children = null if (bufferRows) { - const renderedTileCount = rootComponent.getRenderedTileCount() - children = new Array(renderedTileCount) - - for (let tileStartRow = startRow; tileStartRow < endRow; tileStartRow = tileStartRow + rowsPerTile) { + children = new Array(rootComponent.renderedTileStartRows.length) + for (let i = 0; i < rootComponent.renderedTileStartRows.length; i++) { + const tileStartRow = rootComponent.renderedTileStartRows[i] const tileEndRow = Math.min(endRow, tileStartRow + rowsPerTile) const tileChildren = new Array(tileEndRow - tileStartRow) for (let row = tileStartRow; row < tileEndRow; row++) { - const i = row - startRow - const key = keys[i] - const softWrapped = softWrappedFlags[i] - const foldable = foldableFlags[i] - const bufferRow = bufferRows[i] + const j = row - startRow + const key = keys[j] + const softWrapped = softWrappedFlags[j] + const foldable = foldableFlags[j] + const bufferRow = bufferRows[j] let className = 'line-number' if (foldable) className = className + ' foldable' @@ -2954,13 +2982,12 @@ class LineNumberGutterComponent { ) } - const tileIndex = rootComponent.tileIndexForTileStartRow(tileStartRow) const tileTop = rootComponent.pixelPositionBeforeBlocksForRow(tileStartRow) const tileBottom = rootComponent.pixelPositionBeforeBlocksForRow(tileEndRow) const tileHeight = tileBottom - tileTop - children[tileIndex] = $.div({ - key: tileIndex, + children[i] = $.div({ + key: rootComponent.idsByTileStartRow.get(tileStartRow), style: { contain: 'strict', overflow: 'hidden', From ca3395b1b28c2db9630652554ef50202afd34253 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 9 May 2017 18:24:24 +0200 Subject: [PATCH 357/403] Null out component when editor is destroyed to minimize leaks Signed-off-by: Nathan Sobo --- src/text-editor.coffee | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/text-editor.coffee b/src/text-editor.coffee index df3f4a9b531..4ec091954ce 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -478,7 +478,9 @@ class TextEditor extends Model @gutterContainer.destroy() @emitter.emit 'did-destroy' @emitter.clear() - @editorElement = null + @component?.element.component = null + @component = null + @lineNumberGutter.element = null ### Section: Event Subscription From f58fd749aaabe4851f50e2d02436db815a699449 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 9 May 2017 18:30:44 +0200 Subject: [PATCH 358/403] Dispose nodes associated with a LineComponent when replacing it --- src/text-editor-component.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index da62af98cd1..fe3eb15c38d 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -3453,12 +3453,7 @@ class LinesTileComponent { textNodesByScreenLineId }) this.element.insertBefore(newScreenLineComponent.element, oldScreenLineComponent.element) - // Instead of calling destroy on the component here we can simply - // remove its associated element, thus skipping the - // lineNodesByScreenLineId bookkeeping. This is possible because - // lineNodesByScreenLineId has already been updated when creating the - // new screen line component. - oldScreenLineComponent.element.remove() + oldScreenLineComponent.destroy() this.lineComponents[lineComponentIndex] = newScreenLineComponent oldScreenLineIndex++ From f2aba0afc2fbe9978d415bf56a899090f856442d Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 9 May 2017 19:37:26 +0200 Subject: [PATCH 359/403] :arrow_up: etch --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 60c2eecc82b..d5714020b89 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "color": "^0.7.3", "dedent": "^0.6.0", "devtron": "1.3.0", - "etch": "^0.12.2", + "etch": "^0.12.3", "event-kit": "^2.3.0", "find-parent-dir": "^0.3.0", "first-mate": "7.0.4", From 4eecf8d1a683e6b4e4c3b16a08907b1bfd7f0af7 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 9 May 2017 15:09:14 -0600 Subject: [PATCH 360/403] Don't change number of tiles based on block decorations This means we may render more tiles than necessary when we have block decorations, but it prevents changing the number of rendered tiles during scrolling with certain combinations of line height and editor height. If it ever becomes a problem we can get smarter about subtracting the height of the visible block decorations from the editor height, but for now this gives us more reliable performance for the common case. --- spec/text-editor-component-spec.js | 72 +++++++++++++++--------------- src/text-editor-component.js | 10 +++-- 2 files changed, 43 insertions(+), 39 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 78ab6a8e2c4..66bbb6b971e 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1694,7 +1694,7 @@ describe('TextEditorComponent', () => { const {component, element} = buildComponent({editor, rowsPerTile: 3}) await setEditorHeightInLines(component, 4) expect(component.getRenderedStartRow()).toBe(0) - expect(component.getRenderedEndRow()).toBe(6) + expect(component.getRenderedEndRow()).toBe(9) expect(component.getScrollHeight()).toBe( editor.getScreenLineCount() * component.getLineHeight() + getElementHeight(item1) + getElementHeight(item2) @@ -1704,7 +1704,7 @@ describe('TextEditorComponent', () => { {tileStartRow: 3, height: 3 * component.getLineHeight()} ]) assertLinesAreAlignedWithLineNumbers(component) - expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(9) expect(item1.previousSibling.className).toBe('highlights') expect(item1.nextSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 1)) @@ -1717,7 +1717,7 @@ describe('TextEditorComponent', () => { const {item: item6, decoration: decoration6} = createBlockDecorationAtScreenRow(editor, 12, {height: 66, position: 'after'}) await component.getNextUpdatePromise() expect(component.getRenderedStartRow()).toBe(0) - expect(component.getRenderedEndRow()).toBe(6) + expect(component.getRenderedEndRow()).toBe(9) expect(component.getScrollHeight()).toBe( editor.getScreenLineCount() * component.getLineHeight() + getElementHeight(item1) + getElementHeight(item2) + getElementHeight(item3) + @@ -1728,22 +1728,22 @@ describe('TextEditorComponent', () => { {tileStartRow: 3, height: 3 * component.getLineHeight() + getElementHeight(item3)} ]) assertLinesAreAlignedWithLineNumbers(component) - expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(9) expect(item1.previousSibling.className).toBe('highlights') expect(item1.nextSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 1)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 2)) expect(item3.previousSibling).toBe(lineNodeForScreenRow(component, 3)) expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 4)) - expect(element.contains(item4)).toBe(false) - expect(element.contains(item5)).toBe(false) + expect(item4.nextSibling).toBe(lineNodeForScreenRow(component, 7)) + expect(item5.previousSibling).toBe(lineNodeForScreenRow(component, 7)) expect(element.contains(item6)).toBe(false) // destroy decoration1 decoration1.destroy() await component.getNextUpdatePromise() expect(component.getRenderedStartRow()).toBe(0) - expect(component.getRenderedEndRow()).toBe(6) + expect(component.getRenderedEndRow()).toBe(9) expect(component.getScrollHeight()).toBe( editor.getScreenLineCount() * component.getLineHeight() + getElementHeight(item2) + getElementHeight(item3) + @@ -1754,14 +1754,14 @@ describe('TextEditorComponent', () => { {tileStartRow: 3, height: 3 * component.getLineHeight() + getElementHeight(item3)} ]) assertLinesAreAlignedWithLineNumbers(component) - expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(9) expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 1)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 2)) expect(item3.previousSibling).toBe(lineNodeForScreenRow(component, 3)) expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 4)) - expect(element.contains(item4)).toBe(false) - expect(element.contains(item5)).toBe(false) + expect(item4.nextSibling).toBe(lineNodeForScreenRow(component, 7)) + expect(item5.previousSibling).toBe(lineNodeForScreenRow(component, 7)) expect(element.contains(item6)).toBe(false) // move decoration2 and decoration3 @@ -1769,7 +1769,7 @@ describe('TextEditorComponent', () => { decoration3.getMarker().setHeadScreenPosition([0, 0]) await component.getNextUpdatePromise() expect(component.getRenderedStartRow()).toBe(0) - expect(component.getRenderedEndRow()).toBe(6) + expect(component.getRenderedEndRow()).toBe(9) expect(component.getScrollHeight()).toBe( editor.getScreenLineCount() * component.getLineHeight() + getElementHeight(item2) + getElementHeight(item3) + @@ -1780,21 +1780,21 @@ describe('TextEditorComponent', () => { {tileStartRow: 3, height: 3 * component.getLineHeight()} ]) assertLinesAreAlignedWithLineNumbers(component) - expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(9) expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) expect(item3.previousSibling.className).toBe('highlights') expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0)) - expect(element.contains(item4)).toBe(false) - expect(element.contains(item5)).toBe(false) + expect(item4.nextSibling).toBe(lineNodeForScreenRow(component, 7)) + expect(item5.previousSibling).toBe(lineNodeForScreenRow(component, 7)) expect(element.contains(item6)).toBe(false) // change the text editor.getBuffer().setTextInRange([[0, 5], [0, 5]], '\n\n') await component.getNextUpdatePromise() expect(component.getRenderedStartRow()).toBe(0) - expect(component.getRenderedEndRow()).toBe(6) + expect(component.getRenderedEndRow()).toBe(9) expect(component.getScrollHeight()).toBe( editor.getScreenLineCount() * component.getLineHeight() + getElementHeight(item2) + getElementHeight(item3) + @@ -1805,7 +1805,7 @@ describe('TextEditorComponent', () => { {tileStartRow: 3, height: 3 * component.getLineHeight() + getElementHeight(item2)} ]) assertLinesAreAlignedWithLineNumbers(component) - expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(9) expect(element.contains(item1)).toBe(false) expect(item2.previousSibling.className).toBe('highlights') expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 3)) @@ -1818,7 +1818,7 @@ describe('TextEditorComponent', () => { // scroll past the first tile await setScrollTop(component, 3 * component.getLineHeight() + getElementHeight(item3)) expect(component.getRenderedStartRow()).toBe(3) - expect(component.getRenderedEndRow()).toBe(9) + expect(component.getRenderedEndRow()).toBe(12) expect(component.getScrollHeight()).toBe( editor.getScreenLineCount() * component.getLineHeight() + getElementHeight(item2) + getElementHeight(item3) + @@ -1829,13 +1829,13 @@ describe('TextEditorComponent', () => { {tileStartRow: 6, height: 3 * component.getLineHeight()} ]) assertLinesAreAlignedWithLineNumbers(component) - expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(9) expect(element.contains(item1)).toBe(false) expect(item2.previousSibling.className).toBe('highlights') expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 3)) expect(element.contains(item3)).toBe(false) - expect(element.contains(item4)).toBe(false) - expect(element.contains(item5)).toBe(false) + expect(item4.nextSibling).toBe(lineNodeForScreenRow(component, 9)) + expect(item5.previousSibling).toBe(lineNodeForScreenRow(component, 9)) expect(element.contains(item6)).toBe(false) await setScrollTop(component, 0) @@ -1843,7 +1843,7 @@ describe('TextEditorComponent', () => { editor.undo() await component.getNextUpdatePromise() expect(component.getRenderedStartRow()).toBe(0) - expect(component.getRenderedEndRow()).toBe(6) + expect(component.getRenderedEndRow()).toBe(9) expect(component.getScrollHeight()).toBe( editor.getScreenLineCount() * component.getLineHeight() + getElementHeight(item2) + getElementHeight(item3) + @@ -1854,14 +1854,14 @@ describe('TextEditorComponent', () => { {tileStartRow: 3, height: 3 * component.getLineHeight()} ]) assertLinesAreAlignedWithLineNumbers(component) - expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(9) expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) expect(item3.previousSibling.className).toBe('highlights') expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0)) - expect(element.contains(item4)).toBe(false) - expect(element.contains(item5)).toBe(false) + expect(item4.nextSibling).toBe(lineNodeForScreenRow(component, 7)) + expect(item5.previousSibling).toBe(lineNodeForScreenRow(component, 7)) expect(element.contains(item6)).toBe(false) // invalidate decorations. this also tests a case where two decorations in @@ -1875,7 +1875,7 @@ describe('TextEditorComponent', () => { component.invalidateBlockDecorationDimensions(decoration3) await component.getNextUpdatePromise() expect(component.getRenderedStartRow()).toBe(0) - expect(component.getRenderedEndRow()).toBe(6) + expect(component.getRenderedEndRow()).toBe(9) expect(component.getScrollHeight()).toBe( editor.getScreenLineCount() * component.getLineHeight() + getElementHeight(item2) + getElementHeight(item3) + @@ -1886,14 +1886,14 @@ describe('TextEditorComponent', () => { {tileStartRow: 3, height: 3 * component.getLineHeight()} ]) assertLinesAreAlignedWithLineNumbers(component) - expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(9) expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) expect(item3.previousSibling.className).toBe('highlights') expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0)) - expect(element.contains(item4)).toBe(false) - expect(element.contains(item5)).toBe(false) + expect(item4.nextSibling).toBe(lineNodeForScreenRow(component, 7)) + expect(item5.previousSibling).toBe(lineNodeForScreenRow(component, 7)) expect(element.contains(item6)).toBe(false) // make decoration before row 0 as wide as the editor, and insert some text into it so that it wraps. @@ -1914,7 +1914,7 @@ describe('TextEditorComponent', () => { ) + 'px' await component.getNextUpdatePromise() expect(component.getRenderedStartRow()).toBe(0) - expect(component.getRenderedEndRow()).toBe(6) + expect(component.getRenderedEndRow()).toBe(9) expect(component.getScrollHeight()).toBe( editor.getScreenLineCount() * component.getLineHeight() + getElementHeight(item2) + getElementHeight(item3) + @@ -1925,14 +1925,14 @@ describe('TextEditorComponent', () => { {tileStartRow: 3, height: 3 * component.getLineHeight()} ]) assertLinesAreAlignedWithLineNumbers(component) - expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(9) expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) expect(item3.previousSibling.className).toBe('highlights') expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0)) - expect(element.contains(item4)).toBe(false) - expect(element.contains(item5)).toBe(false) + expect(item3.nextSibling).toBe(lineNodeForScreenRow(component, 0)) + expect(item4.nextSibling).toBe(lineNodeForScreenRow(component, 7)) expect(element.contains(item6)).toBe(false) // make the editor taller and wider and the same time, ensuring the number @@ -1940,7 +1940,7 @@ describe('TextEditorComponent', () => { setEditorHeightInLines(component, 13) await setEditorWidthInCharacters(component, 50) expect(component.getRenderedStartRow()).toBe(0) - expect(component.getRenderedEndRow()).toBe(9) + expect(component.getRenderedEndRow()).toBe(13) expect(component.getScrollHeight()).toBe( editor.getScreenLineCount() * component.getLineHeight() + getElementHeight(item2) + getElementHeight(item3) + @@ -1952,7 +1952,7 @@ describe('TextEditorComponent', () => { {tileStartRow: 6, height: 3 * component.getLineHeight() + getElementHeight(item4) + getElementHeight(item5)}, ]) assertLinesAreAlignedWithLineNumbers(component) - expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(9) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(13) expect(element.contains(item1)).toBe(false) expect(item2.previousSibling).toBe(lineNodeForScreenRow(component, 0)) expect(item2.nextSibling).toBe(lineNodeForScreenRow(component, 1)) @@ -1962,7 +1962,7 @@ describe('TextEditorComponent', () => { expect(item4.nextSibling).toBe(lineNodeForScreenRow(component, 7)) expect(item5.previousSibling).toBe(lineNodeForScreenRow(component, 7)) expect(item5.nextSibling).toBe(lineNodeForScreenRow(component, 8)) - expect(element.contains(item6)).toBe(false) + expect(item6.previousSibling).toBe(lineNodeForScreenRow(component, 12)) }) function createBlockDecorationAtScreenRow(editor, screenRow, {height, margin, position}) { @@ -3104,7 +3104,7 @@ describe('TextEditorComponent', () => { expect(element.querySelectorAll('.line:not(.dummy)').length).toBeGreaterThan(initialRenderedLineCount) verifyCursorPosition(component, cursorNode, 1, 29) - element.style.fontSize = initialFontSize + 5 + 'px' + element.style.fontSize = initialFontSize + 10 + 'px' TextEditor.didUpdateStyles() await component.getNextUpdatePromise() expect(editor.getDefaultCharWidth()).toBeGreaterThan(initialBaseCharacterWidth) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index fe3eb15c38d..ea28c0f3c3f 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2553,12 +2553,16 @@ class TextEditorComponent { return this.derivedDimensionsCache.lastVisibleRow } + // We may render more tiles than needed if some contain block decorations, + // but keeping this calculation simple ensures the number of tiles remains + // fixed for a given editor height, which eliminates situations where a + // tile is repeatedly added and removed during scrolling in certain + // comibinations of editor height and line height. getVisibleTileCount () { if (this.derivedDimensionsCache.visibleTileCount == null) { - const visibleRowCount = this.getLastVisibleRow() - this.getFirstVisibleRow() - this.derivedDimensionsCache.visibleTileCount = Math.ceil(visibleRowCount / this.getRowsPerTile()) + 1 + const editorHeightInTiles = this.getScrollContainerHeight() / this.getLineHeight() / this.getRowsPerTile() + this.derivedDimensionsCache.visibleTileCount = Math.ceil(editorHeightInTiles) + 1 } - return this.derivedDimensionsCache.visibleTileCount } From ebf2aaa688ddf434bf86f9d372fdd1f57da6904b Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 10 May 2017 10:44:19 +0200 Subject: [PATCH 361/403] Avoid deopt when calling TokenizedLine.prototype.getTokenIterator --- src/tokenized-line.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tokenized-line.coffee b/src/tokenized-line.coffee index a65b3a79309..c039109f47a 100644 --- a/src/tokenized-line.coffee +++ b/src/tokenized-line.coffee @@ -12,7 +12,7 @@ class TokenizedLine {@openScopes, @text, @tags, @ruleStack, @tokenIterator} = properties - getTokenIterator: -> @tokenIterator.reset(this, arguments...) + getTokenIterator: -> @tokenIterator.reset(this) Object.defineProperty @prototype, 'tokens', get: -> iterator = @getTokenIterator() From ab20fc3b19deeedfb47fb15f36bbe1d687df889b Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 10 May 2017 10:48:16 +0200 Subject: [PATCH 362/403] Don't populate more screen rows than necessary See 4eecf8d1a683e6b4e4c3b16a08907b1bfd7f0af7. --- src/text-editor-component.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index ea28c0f3c3f..7002a746fb1 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2673,11 +2673,10 @@ class TextEditorComponent { // Ensure the spatial index is populated with rows that are currently // visible so we *at least* get the longest row in the visible range. populateVisibleRowRange () { - const lastPossibleVisibleRow = this.rowForPixelPosition(this.getScrollBottom()) - const maxPossibleVisibleRows = lastPossibleVisibleRow - this.getFirstVisibleRow() - const maxPossibleVisibleTileCount = Math.ceil(maxPossibleVisibleRows / this.getRowsPerTile()) + 1 - const lastPossibleRenderedRow = this.getRenderedStartRow() + maxPossibleVisibleTileCount * this.getRowsPerTile() - this.props.model.displayLayer.populateSpatialIndexIfNeeded(Infinity, lastPossibleRenderedRow) + const editorHeightInTiles = this.getScrollContainerHeight() / this.getLineHeight() + const visibleTileCount = Math.ceil(editorHeightInTiles) + 1 + const lastRenderedRow = this.getRenderedStartRow() + (visibleTileCount * this.getRowsPerTile()) + this.props.model.displayLayer.populateSpatialIndexIfNeeded(Infinity, lastRenderedRow) } populateVisibleTiles () { From 955cf98feb28701d5de2b144889fdbeb538411b1 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 10 May 2017 13:45:19 +0200 Subject: [PATCH 363/403] Use bufferRowsForScreenRows to compute line numbers --- src/text-editor-component.js | 8 ++++---- src/text-editor.coffee | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 7002a746fb1..21bda2c93b9 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -819,8 +819,9 @@ class TextEditorComponent { const endRow = this.getRenderedEndRow() const renderedRowCount = this.getRenderedRowCount() - const {bufferRows, keys, softWrappedFlags, foldableFlags} = this.lineNumbersToRender - bufferRows.length = renderedRowCount + const {keys, softWrappedFlags, foldableFlags} = this.lineNumbersToRender + const bufferRows = model.bufferRowsForScreenRows(startRow, endRow) + this.lineNumbersToRender.bufferRows = bufferRows keys.length = renderedRowCount foldableFlags.length = renderedRowCount @@ -828,8 +829,7 @@ class TextEditorComponent { let softWrapCount = 0 for (let row = startRow; row < endRow; row++) { const i = row - startRow - const bufferRow = model.bufferRowForScreenRow(row) - bufferRows[i] = bufferRow + const bufferRow = bufferRows[i] if (bufferRow === previousBufferRow) { softWrapCount++ softWrappedFlags[i] = true diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 4ec091954ce..7063068b7e7 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -1039,8 +1039,7 @@ class TextEditor extends Model @displayLayer.translateScreenPosition(Point(screenRow, 0)).row bufferRowsForScreenRows: (startScreenRow, endScreenRow) -> - for screenRow in [startScreenRow..endScreenRow] - @bufferRowForScreenRow(screenRow) + @displayLayer.bufferRowsForScreenRows(startScreenRow, endScreenRow) screenRowForBufferRow: (row) -> @displayLayer.translateBufferPosition(Point(row, 0)).row From 42b397e3b9d778cffeac211ed6ebfa9b4e2877e8 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 10 May 2017 16:24:55 +0200 Subject: [PATCH 364/403] :arrow_up: line-top-index --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d5714020b89..056ffa251bc 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "jquery": "2.1.4", "key-path-helpers": "^0.4.0", "less-cache": "1.1.0", - "line-top-index": "0.3.0", + "line-top-index": "0.3.1", "marked": "^0.3.6", "minimatch": "^3.0.3", "mocha": "2.5.1", From c12882a3713ce439c9d5c975269748805cceaec1 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 10 May 2017 16:25:53 +0200 Subject: [PATCH 365/403] :arrow_up: etch --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 056ffa251bc..b59e725ada3 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "color": "^0.7.3", "dedent": "^0.6.0", "devtron": "1.3.0", - "etch": "^0.12.3", + "etch": "^0.12.4", "event-kit": "^2.3.0", "find-parent-dir": "^0.3.0", "first-mate": "7.0.4", From e89552ea753c9d966c07ea2ac4130ec4b9e8805c Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 10 May 2017 17:54:14 +0200 Subject: [PATCH 366/403] Ignore mismatched tags between lines in TokenizedBufferIterator Previously the DisplayLayer would have issues when closing tags that had not been previously opened, requiring us to guard against mismatched scopes at the frontier of asynchronous tokenization (see 5cfe9716 for more information). Now the DisplayLayer gracefully handles closing tags that had not been opened, meaning we can eliminate this costly logic. Signed-off-by: Nathan Sobo --- spec/tokenized-buffer-iterator-spec.js | 53 -------------------------- src/tokenized-buffer-iterator.js | 42 +++----------------- 2 files changed, 6 insertions(+), 89 deletions(-) diff --git a/spec/tokenized-buffer-iterator-spec.js b/spec/tokenized-buffer-iterator-spec.js index 14e656a8fc7..1b26f7b38d9 100644 --- a/spec/tokenized-buffer-iterator-spec.js +++ b/spec/tokenized-buffer-iterator-spec.js @@ -106,58 +106,5 @@ describe('TokenizedBufferIterator', () => { expect(iterator.getCloseScopeIds()).toEqual([257]) expect(iterator.getOpenScopeIds()).toEqual([]) }) - - it("reports a boundary at line end if the next line's open scopes don't match the containing tags for the current line", () => { - const tokenizedBuffer = { - tokenizedLineForRow (row) { - if (row === 0) { - return { - tags: [-1, 3, -2, -3], - text: 'bar', - openScopes: [] - } - } else if (row === 1) { - return { - tags: [3], - text: 'baz', - openScopes: [-1] - } - } else if (row === 2) { - return { - tags: [-2], - text: '', - openScopes: [-1] - } - } - } - } - - const iterator = new TokenizedBufferIterator(tokenizedBuffer) - - iterator.seek(Point(0, 0)) - expect(iterator.getPosition()).toEqual(Point(0, 0)) - expect(iterator.getCloseScopeIds()).toEqual([]) - expect(iterator.getOpenScopeIds()).toEqual([257]) - - iterator.moveToSuccessor() - expect(iterator.getPosition()).toEqual(Point(0, 3)) - expect(iterator.getCloseScopeIds()).toEqual([257]) - expect(iterator.getOpenScopeIds()).toEqual([259]) - - iterator.moveToSuccessor() - expect(iterator.getPosition()).toEqual(Point(0, 3)) - expect(iterator.getCloseScopeIds()).toEqual([259]) - expect(iterator.getOpenScopeIds()).toEqual([]) - - iterator.moveToSuccessor() - expect(iterator.getPosition()).toEqual(Point(1, 0)) - expect(iterator.getCloseScopeIds()).toEqual([]) - expect(iterator.getOpenScopeIds()).toEqual([257]) - - iterator.moveToSuccessor() - expect(iterator.getPosition()).toEqual(Point(2, 0)) - expect(iterator.getCloseScopeIds()).toEqual([257]) - expect(iterator.getOpenScopeIds()).toEqual([]) - }) }) }) diff --git a/src/tokenized-buffer-iterator.js b/src/tokenized-buffer-iterator.js index 908dd53aea2..d22f97874f0 100644 --- a/src/tokenized-buffer-iterator.js +++ b/src/tokenized-buffer-iterator.js @@ -6,7 +6,6 @@ module.exports = class TokenizedBufferIterator { this.tokenizedBuffer = tokenizedBuffer this.openScopeIds = null this.closeScopeIds = null - this.containingScopeIds = null } seek (position) { @@ -16,9 +15,8 @@ module.exports = class TokenizedBufferIterator { const currentLine = this.tokenizedBuffer.tokenizedLineForRow(position.row) this.currentLineTags = currentLine.tags - this.currentLineOpenTags = currentLine.openScopes this.currentLineLength = currentLine.text.length - this.containingScopeIds = this.currentLineOpenTags.map((id) => fromFirstMateScopeId(id)) + const containingScopeIds = currentLine.openScopes.map((id) => fromFirstMateScopeId(id)) let currentColumn = 0 for (let index = 0; index < this.currentLineTags.length; index++) { @@ -31,11 +29,11 @@ module.exports = class TokenizedBufferIterator { currentColumn += tag while (this.closeScopeIds.length > 0) { this.closeScopeIds.shift() - this.containingScopeIds.pop() + containingScopeIds.pop() } while (this.openScopeIds.length > 0) { const openTag = this.openScopeIds.shift() - this.containingScopeIds.push(openTag) + containingScopeIds.push(openTag) } } } else { @@ -48,11 +46,11 @@ module.exports = class TokenizedBufferIterator { } else { while (this.closeScopeIds.length > 0) { this.closeScopeIds.shift() - this.containingScopeIds.pop() + containingScopeIds.pop() } while (this.openScopeIds.length > 0) { const openTag = this.openScopeIds.shift() - this.containingScopeIds.push(openTag) + containingScopeIds.push(openTag) } } } @@ -67,31 +65,16 @@ module.exports = class TokenizedBufferIterator { this.tagIndex = this.currentLineTags.length } this.position = Point(position.row, Math.min(this.currentLineLength, currentColumn)) - return this.containingScopeIds.slice() + return containingScopeIds } moveToSuccessor () { - for (let i = 0; i < this.closeScopeIds.length; i++) { - this.containingScopeIds.pop() - } - for (let i = 0; i < this.openScopeIds.length; i++) { - const tag = this.openScopeIds[i] - this.containingScopeIds.push(tag) - } this.openScopeIds = [] this.closeScopeIds = [] while (true) { if (this.tagIndex === this.currentLineTags.length) { if (this.isAtTagBoundary()) { break - } else if (this.shouldMoveToNextLine) { - this.moveToNextLine() - this.openScopeIds = this.currentLineOpenTags.map((id) => fromFirstMateScopeId(id)) - this.shouldMoveToNextLine = false - } else if (this.nextLineHasMismatchedContainingTags()) { - this.closeScopeIds = this.containingScopeIds.slice().reverse() - this.containingScopeIds = [] - this.shouldMoveToNextLine = true } else if (!this.moveToNextLine()) { return false } @@ -136,18 +119,6 @@ module.exports = class TokenizedBufferIterator { return this.openScopeIds.slice() } - nextLineHasMismatchedContainingTags () { - const line = this.tokenizedBuffer.tokenizedLineForRow(this.position.row + 1) - if (line == null) { - return false - } else { - return ( - this.containingScopeIds.length !== line.openScopes.length || - this.containingScopeIds.some((tag, i) => tag !== fromFirstMateScopeId(line.openScopes[i])) - ) - } - } - moveToNextLine () { this.position = Point(this.position.row + 1, 0) const tokenizedLine = this.tokenizedBuffer.tokenizedLineForRow(this.position.row) @@ -156,7 +127,6 @@ module.exports = class TokenizedBufferIterator { } else { this.currentLineTags = tokenizedLine.tags this.currentLineLength = tokenizedLine.text.length - this.currentLineOpenTags = tokenizedLine.openScopes this.tagIndex = 0 return true } From 58a9682a0d0988e7b851f882785eb5ef617422b0 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 10 May 2017 17:54:58 +0200 Subject: [PATCH 367/403] :arrow_up: text-buffer --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b59e725ada3..3a2b0f8e891 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "sinon": "1.17.4", "source-map-support": "^0.3.2", "temp": "^0.8.3", - "text-buffer": "12.1.1", + "text-buffer": "12.1.2", "typescript-simple": "1.0.0", "underscore-plus": "^1.6.6", "winreg": "^1.2.1", From 3c87b7499e6cbaffa6a1c7b1f2f215d36166c58e Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 10 May 2017 18:48:18 +0200 Subject: [PATCH 368/403] Optimize isFoldable Signed-off-by: Nathan Sobo --- src/tokenized-buffer.coffee | 23 +++++++++++------------ src/tokenized-line.coffee | 31 ++++++++++++++++++++----------- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/tokenized-buffer.coffee b/src/tokenized-buffer.coffee index a828950b38c..8fca6c06bfe 100644 --- a/src/tokenized-buffer.coffee +++ b/src/tokenized-buffer.coffee @@ -268,7 +268,7 @@ class TokenizedBuffer extends Model buildTokenizedLineForRowWithText: (row, text, ruleStack = @stackForRow(row - 1), openScopes = @openScopesForRow(row)) -> lineEnding = @buffer.lineEndingForRow(row) {tags, ruleStack} = @grammar.tokenizeLine(text, ruleStack, row is 0, false) - new TokenizedLine({openScopes, text, tags, ruleStack, lineEnding, @tokenIterator}) + new TokenizedLine({openScopes, text, tags, ruleStack, lineEnding, @tokenIterator, @grammar}) tokenizedLineForRow: (bufferRow) -> if 0 <= bufferRow <= @buffer.getLastRow() @@ -278,7 +278,7 @@ class TokenizedBuffer extends Model text = @buffer.lineForRow(bufferRow) lineEnding = @buffer.lineEndingForRow(bufferRow) tags = [@grammar.startIdForScope(@grammar.scopeName), text.length, @grammar.endIdForScope(@grammar.scopeName)] - @tokenizedLines[bufferRow] = new TokenizedLine({openScopes: [], text, tags, lineEnding, @tokenIterator}) + @tokenizedLines[bufferRow] = new TokenizedLine({openScopes: [], text, tags, lineEnding, @tokenIterator, @grammar}) tokenizedLinesForRows: (startRow, endRow) -> for row in [startRow..endRow] by 1 @@ -344,17 +344,16 @@ class TokenizedBuffer extends Model @indentLevelForLine(line) indentLevelForLine: (line) -> - if match = line.match(/^[\t ]+/) - indentLength = 0 - for character in match[0] - if character is '\t' - indentLength += @getTabLength() - (indentLength % @getTabLength()) - else - indentLength++ + indentLength = 0 + for char in line + if char is '\t' + indentLength += @getTabLength() - (indentLength % @getTabLength()) + else if char is ' ' + indentLength++ + else + break - indentLength / @getTabLength() - else - 0 + indentLength / @getTabLength() scopeDescriptorForPosition: (position) -> {row, column} = @buffer.clipPosition(Point.fromObject(position)) diff --git a/src/tokenized-line.coffee b/src/tokenized-line.coffee index c039109f47a..5a22a297a44 100644 --- a/src/tokenized-line.coffee +++ b/src/tokenized-line.coffee @@ -1,5 +1,5 @@ Token = require './token' -CommentScopeRegex = /(\b|\.)comment/ +CommentScopeRegex = /(\b|\.)comment/ idCounter = 1 @@ -10,7 +10,7 @@ class TokenizedLine return unless properties? - {@openScopes, @text, @tags, @ruleStack, @tokenIterator} = properties + {@openScopes, @text, @tags, @ruleStack, @tokenIterator, @grammar} = properties getTokenIterator: -> @tokenIterator.reset(this) @@ -48,17 +48,26 @@ class TokenizedLine return @isCommentLine if @isCommentLine? @isCommentLine = false - iterator = @getTokenIterator() - while iterator.next() - scopes = iterator.getScopes() - continue if scopes.length is 1 - for scope in scopes - if CommentScopeRegex.test(scope) - @isCommentLine = true - break - break + + for tag in @openScopes + if @isCommentOpenTag(tag) + @isCommentLine = true + return @isCommentLine + + for tag in @tags + if @isCommentOpenTag(tag) + @isCommentLine = true + return @isCommentLine + @isCommentLine + isCommentOpenTag: (tag) -> + if tag < 0 and (tag & 1) is 1 + scope = @grammar.scopeForId(tag) + if CommentScopeRegex.test(scope) + return true + false + tokenAtIndex: (index) -> @tokens[index] From dc6653ffa6bc5226bb8ba5298bb60b990294c7de Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 11 May 2017 10:33:37 +0200 Subject: [PATCH 369/403] Avoid creating a new timeout every time cursor blinking is paused --- src/text-editor-component.js | 37 ++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 21bda2c93b9..eefc8456ae8 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -81,6 +81,10 @@ class TextEditorComponent { this.updatedSynchronously = this.props.updatedSynchronously this.didScrollDummyScrollbar = this.didScrollDummyScrollbar.bind(this) this.didMouseDownOnContent = this.didMouseDownOnContent.bind(this) + this.debouncedResumeCursorBlinking = debounce( + this.resumeCursorBlinking.bind(this), + (this.props.cursorBlinkResumeDelay || CURSOR_BLINK_RESUME_DELAY) + ) this.lineTopIndex = new LineTopIndex() this.updateScheduled = false this.suppressUpdates = false @@ -1829,14 +1833,12 @@ class TextEditorComponent { pauseCursorBlinking () { this.stopCursorBlinking() - if (this.resumeCursorBlinkingTimeoutHandle) { - window.clearTimeout(this.resumeCursorBlinkingTimeoutHandle) - } - this.resumeCursorBlinkingTimeoutHandle = window.setTimeout(() => { - this.cursorsBlinkedOff = true - this.startCursorBlinking() - this.resumeCursorBlinkingTimeoutHandle = null - }, (this.props.cursorBlinkResumeDelay || CURSOR_BLINK_RESUME_DELAY)) + this.debouncedResumeCursorBlinking() + } + + resumeCursorBlinking () { + this.cursorsBlinkedOff = true + this.startCursorBlinking() } stopCursorBlinking () { @@ -3931,3 +3933,22 @@ function constrainRangeToRows (range, startRow, endRow) { } return range } + +function debounce (fn, wait) { + let timestamp, timeout + + function later () { + const last = Date.now() - timestamp + if (last < wait && last >= 0) { + timeout = setTimeout(later, wait - last) + } else { + timeout = null + result = fn() + } + } + + return function() { + timestamp = Date.now() + if (!timeout) timeout = setTimeout(later, wait) + } +} From a0ed201fe82b698c539713aed789c801134f3c58 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 11 May 2017 10:49:47 +0200 Subject: [PATCH 370/403] Fix blurring the editor when no hidden input is present --- src/text-editor-component.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index eefc8456ae8..0e459c46d59 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1421,7 +1421,8 @@ class TextEditorComponent { // listener to be fired, even if other listeners are bound before creating // the component. didBlur (event) { - if (event.relatedTarget === this.refs.cursorsAndInput.refs.hiddenInput) { + const {cursorsAndInput} = this.refs + if (cursorsAndInput && event.relatedTarget === cursorsAndInput.refs.hiddenInput) { event.stopImmediatePropagation() } } From f3a96c968daa127518e8734423bddb54e6892a32 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 11 May 2017 11:00:51 +0200 Subject: [PATCH 371/403] Change editor.bufferRowsForScreenRows to include the supplied endScreenRow --- src/text-editor-component.js | 2 +- src/text-editor.coffee | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 0e459c46d59..9ee5ec7bfca 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -824,7 +824,7 @@ class TextEditorComponent { const renderedRowCount = this.getRenderedRowCount() const {keys, softWrappedFlags, foldableFlags} = this.lineNumbersToRender - const bufferRows = model.bufferRowsForScreenRows(startRow, endRow) + const bufferRows = model.bufferRowsForScreenRows(startRow, endRow - 1) this.lineNumbersToRender.bufferRows = bufferRows keys.length = renderedRowCount foldableFlags.length = renderedRowCount diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 7063068b7e7..a9676069a6e 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -1039,7 +1039,7 @@ class TextEditor extends Model @displayLayer.translateScreenPosition(Point(screenRow, 0)).row bufferRowsForScreenRows: (startScreenRow, endScreenRow) -> - @displayLayer.bufferRowsForScreenRows(startScreenRow, endScreenRow) + @displayLayer.bufferRowsForScreenRows(startScreenRow, endScreenRow + 1) screenRowForBufferRow: (row) -> @displayLayer.translateBufferPosition(Point(row, 0)).row From 39a5705e39384b158eac8aab213011329e7fb0d5 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 11 May 2017 11:05:58 +0200 Subject: [PATCH 372/403] Re-enable a test that was previously failing --- spec/text-editor-spec.coffee | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/spec/text-editor-spec.coffee b/spec/text-editor-spec.coffee index cf6a7e30381..355105ad3e4 100644 --- a/spec/text-editor-spec.coffee +++ b/spec/text-editor-spec.coffee @@ -4805,10 +4805,7 @@ describe "TextEditor", -> expect(buffer.getLineCount()).toBe(count - 1) describe "when the line being deleted preceeds a fold, and the command is undone", -> - # TODO: This seemed to have only been passing due to an accident in the text - # buffer implementation. Once we moved selections to a different layer it - # broke. We need to revisit our representation of folds and then reenable it. - xit "restores the line and preserves the fold", -> + it "restores the line and preserves the fold", -> editor.setCursorBufferPosition([4]) editor.foldCurrentRow() expect(editor.isFoldedAtScreenRow(4)).toBeTruthy() From 32f03c8a22e8aa370a546d55ab18db53f4ae8a79 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 11 May 2017 11:07:21 +0200 Subject: [PATCH 373/403] Delete unused code --- spec/dom-element-pool-spec.js | 115 ---------- src/dom-element-pool.js | 89 -------- src/text-editor-element-old.coffee | 332 ----------------------------- 3 files changed, 536 deletions(-) delete mode 100644 spec/dom-element-pool-spec.js delete mode 100644 src/dom-element-pool.js delete mode 100644 src/text-editor-element-old.coffee diff --git a/spec/dom-element-pool-spec.js b/spec/dom-element-pool-spec.js deleted file mode 100644 index 91120ee4827..00000000000 --- a/spec/dom-element-pool-spec.js +++ /dev/null @@ -1,115 +0,0 @@ -const DOMElementPool = require ('../src/dom-element-pool') - -describe('DOMElementPool', function () { - let domElementPool - - beforeEach(() => { - domElementPool = new DOMElementPool() - spyOn(atom, 'isReleasedVersion').andReturn(true) - }) - - it('builds DOM nodes, recycling them when they are freed', function () { - let elements - const [div, span1, span2, span3, span4, span5, textNode] = Array.from(elements = [ - domElementPool.buildElement('div', 'foo'), - domElementPool.buildElement('span'), - domElementPool.buildElement('span'), - domElementPool.buildElement('span'), - domElementPool.buildElement('span'), - domElementPool.buildElement('span'), - domElementPool.buildText('Hello world!') - ]) - - expect(div.className).toBe('foo') - div.textContent = 'testing' - div.style.backgroundColor = 'red' - div.dataset.foo = 'bar' - - expect(textNode.textContent).toBe('Hello world!') - - div.appendChild(span1) - span1.appendChild(span2) - div.appendChild(span3) - span3.appendChild(span4) - span4.appendChild(textNode) - - domElementPool.freeElementAndDescendants(div) - domElementPool.freeElementAndDescendants(span5) - - expect(elements.includes(domElementPool.buildElement('div'))).toBe(true) - expect(elements.includes(domElementPool.buildElement('span'))).toBe(true) - expect(elements.includes(domElementPool.buildElement('span'))).toBe(true) - expect(elements.includes(domElementPool.buildElement('span'))).toBe(true) - expect(elements.includes(domElementPool.buildElement('span'))).toBe(true) - expect(elements.includes(domElementPool.buildElement('span'))).toBe(true) - expect(elements.includes(domElementPool.buildText('another text'))).toBe(true) - - expect(elements.includes(domElementPool.buildElement('div'))).toBe(false) - expect(elements.includes(domElementPool.buildElement('span'))).toBe(false) - expect(elements.includes(domElementPool.buildText('unexisting'))).toBe(false) - - expect(div.className).toBe('') - expect(div.textContent).toBe('') - expect(div.style.backgroundColor).toBe('') - expect(div.dataset.foo).toBeUndefined() - - expect(textNode.textContent).toBe('another text') - }) - - it('forgets free nodes after being cleared', function () { - const span = domElementPool.buildElement('span') - const div = domElementPool.buildElement('div') - domElementPool.freeElementAndDescendants(span) - domElementPool.freeElementAndDescendants(div) - - domElementPool.clear() - - expect(domElementPool.buildElement('span')).not.toBe(span) - expect(domElementPool.buildElement('div')).not.toBe(div) - }) - - it('does not attempt to free nodes that were not created by the pool', () => { - let assertionFailure - atom.onDidFailAssertion((error) => assertionFailure = error) - - const foreignDiv = document.createElement('div') - const div = domElementPool.buildElement('div') - div.appendChild(foreignDiv) - domElementPool.freeElementAndDescendants(div) - const span = domElementPool.buildElement('span') - span.appendChild(foreignDiv) - domElementPool.freeElementAndDescendants(span) - - expect(assertionFailure).toBeUndefined() - }) - - it('fails an assertion when freeing the same element twice', function () { - let assertionFailure - atom.onDidFailAssertion((error) => assertionFailure = error) - - const div = domElementPool.buildElement('div') - div.textContent = 'testing' - domElementPool.freeElementAndDescendants(div) - expect(assertionFailure).toBeUndefined() - domElementPool.freeElementAndDescendants(div) - expect(assertionFailure.message).toBe('Assertion failed: The element has already been freed!') - expect(assertionFailure.metadata.content).toBe('
testing
') - }) - - it('fails an assertion when freeing the same text node twice', function () { - let assertionFailure - atom.onDidFailAssertion((error) => assertionFailure = error) - - const node = domElementPool.buildText('testing') - domElementPool.freeElementAndDescendants(node) - expect(assertionFailure).toBeUndefined() - domElementPool.freeElementAndDescendants(node) - expect(assertionFailure.message).toBe('Assertion failed: The element has already been freed!') - expect(assertionFailure.metadata.content).toBe('testing') - }) - - it('throws an error when trying to free an invalid element', function () { - expect(() => domElementPool.freeElementAndDescendants(null)).toThrow() - expect(() => domElementPool.freeElementAndDescendants(undefined)).toThrow() - }) -}) diff --git a/src/dom-element-pool.js b/src/dom-element-pool.js deleted file mode 100644 index 0fef02deebd..00000000000 --- a/src/dom-element-pool.js +++ /dev/null @@ -1,89 +0,0 @@ -module.exports = -class DOMElementPool { - constructor () { - this.managedElements = new Set() - this.freeElementsByTagName = new Map() - this.freedElements = new Set() - } - - clear () { - this.managedElements.clear() - this.freedElements.clear() - this.freeElementsByTagName.clear() - } - - buildElement (tagName, className) { - const elements = this.freeElementsByTagName.get(tagName) - let element = elements ? elements.pop() : null - if (element) { - for (let dataId in element.dataset) { delete element.dataset[dataId] } - element.removeAttribute('style') - if (className) { - element.className = className - } else { - element.removeAttribute('class') - } - while (element.firstChild) { - element.removeChild(element.firstChild) - } - this.freedElements.delete(element) - } else { - element = document.createElement(tagName) - if (className) { - element.className = className - } - this.managedElements.add(element) - } - return element - } - - buildText (textContent) { - const elements = this.freeElementsByTagName.get('#text') - let element = elements ? elements.pop() : null - if (element) { - element.textContent = textContent - this.freedElements.delete(element) - } else { - element = document.createTextNode(textContent) - this.managedElements.add(element) - } - return element - } - - freeElementAndDescendants (element) { - this.free(element) - element.remove() - } - - freeDescendants (element) { - while (element.firstChild) { - this.free(element.firstChild) - element.removeChild(element.firstChild) - } - } - - free (element) { - if (element == null) { throw new Error('The element cannot be null or undefined.') } - if (!this.managedElements.has(element)) return - if (this.freedElements.has(element)) { - atom.assert(false, 'The element has already been freed!', { - content: element instanceof window.Text ? element.textContent : element.outerHTML - }) - return - } - - const tagName = element.nodeName.toLowerCase() - let elements = this.freeElementsByTagName.get(tagName) - if (!elements) { - elements = [] - this.freeElementsByTagName.set(tagName, elements) - } - elements.push(element) - this.freedElements.add(element) - - for (let i = element.childNodes.length - 1; i >= 0; i--) { - const descendant = element.childNodes[i] - this.free(descendant) - } - } -} diff --git a/src/text-editor-element-old.coffee b/src/text-editor-element-old.coffee deleted file mode 100644 index 88793dec3dd..00000000000 --- a/src/text-editor-element-old.coffee +++ /dev/null @@ -1,332 +0,0 @@ -Grim = require 'grim' -{Emitter, CompositeDisposable} = require 'event-kit' -TextBuffer = require 'text-buffer' -TextEditorComponent = require './text-editor-component' - -class TextEditorElement extends HTMLElement - model: null - componentDescriptor: null - component: null - attached: false - tileSize: null - focusOnAttach: false - hasTiledRendering: true - logicalDisplayBuffer: true - lightDOM: true - - createdCallback: -> - # Use globals when the following instance variables aren't set. - @themes = atom.themes - @workspace = atom.workspace - @assert = atom.assert - @views = atom.views - @styles = atom.styles - - @emitter = new Emitter - @subscriptions = new CompositeDisposable - - @hiddenInputElement = document.createElement('input') - @hiddenInputElement.classList.add('hidden-input') - @hiddenInputElement.setAttribute('tabindex', -1) - @hiddenInputElement.setAttribute('data-react-skip-selection-restoration', true) - @hiddenInputElement.style['-webkit-transform'] = 'translateZ(0)' - @hiddenInputElement.addEventListener 'paste', (event) -> event.preventDefault() - - @addEventListener 'focus', @focused.bind(this) - @addEventListener 'blur', @blurred.bind(this) - @hiddenInputElement.addEventListener 'focus', @focused.bind(this) - @hiddenInputElement.addEventListener 'blur', @inputNodeBlurred.bind(this) - - @classList.add('editor') - @setAttribute('tabindex', -1) - - initializeContent: (attributes) -> - Object.defineProperty(this, 'shadowRoot', { - get: => - Grim.deprecate(""" - The contents of `atom-text-editor` elements are no longer encapsulated - within a shadow DOM boundary. Please, stop using `shadowRoot` and access - the editor contents directly instead. - """) - this - }) - @rootElement = document.createElement('div') - @rootElement.classList.add('editor--private') - @appendChild(@rootElement) - - attachedCallback: -> - @buildModel() unless @getModel()? - @assert(@model.isAlive(), "Attaching a view for a destroyed editor") - @mountComponent() unless @component? - @component.didAttach() - @listenForComponentEvents() - if @hasFocus() - @focused() - @emitter.emit("did-attach") - - detachedCallback: -> - @unmountComponent() - @subscriptions.dispose() - @subscriptions = new CompositeDisposable - @emitter.emit("did-detach") - - listenForComponentEvents: -> - @subscriptions.add @component.onDidChangeScrollTop => - @emitter.emit("did-change-scroll-top", arguments...) - @subscriptions.add @component.onDidChangeScrollLeft => - @emitter.emit("did-change-scroll-left", arguments...) - - initialize: (model, {@views, @themes, @workspace, @assert, @styles}) -> - throw new Error("Must pass a views parameter when initializing TextEditorElements") unless @views? - throw new Error("Must pass a themes parameter when initializing TextEditorElements") unless @themes? - throw new Error("Must pass a workspace parameter when initializing TextEditorElements") unless @workspace? - throw new Error("Must pass an assert parameter when initializing TextEditorElements") unless @assert? - throw new Error("Must pass a styles parameter when initializing TextEditorElements") unless @styles? - - @setModel(model) - this - - setModel: (model) -> - throw new Error("Model already assigned on TextEditorElement") if @model? - return if model.isDestroyed() - - @model = model - @model.setUpdatedSynchronously(@isUpdatedSynchronously()) - @initializeContent() - @mountComponent() - @component.didAttach() if document.contains(this) - @addGrammarScopeAttribute() - @addMiniAttribute() if @model.isMini() - @addEncodingAttribute() - @model.onDidChangeGrammar => @addGrammarScopeAttribute() - @model.onDidChangeEncoding => @addEncodingAttribute() - @model.onDidDestroy => @unmountComponent() - @model.onDidChangeMini (mini) => if mini then @addMiniAttribute() else @removeMiniAttribute() - @model - - getModel: -> - @model ? @buildModel() - - buildModel: -> - @setModel(@workspace.buildTextEditor( - buffer: new TextBuffer({ - text: @textContent - shouldDestroyOnFileDelete: - -> atom.config.get('core.closeDeletedFileTabs')}) - softWrapped: false - tabLength: 2 - softTabs: true - mini: @hasAttribute('mini') - lineNumberGutterVisible: not @hasAttribute('gutter-hidden') - placeholderText: @getAttribute('placeholder-text') - )) - - mountComponent: -> - @component = new TextEditorComponent( - hostElement: this - editor: @model - tileSize: @tileSize - views: @views - themes: @themes - styles: @styles - workspace: @workspace - assert: @assert, - hiddenInputElement: @hiddenInputElement - ) - @rootElement.appendChild(@component.getDomNode()) - - unmountComponent: -> - if @component? - @component.destroy() - @component.getDomNode().remove() - @component = null - - focused: (event) -> - @component?.focused() - @hiddenInputElement.focus() - - blurred: (event) -> - if event.relatedTarget is @hiddenInputElement - event.stopImmediatePropagation() - return - @component?.blurred() - - inputNodeBlurred: (event) -> - if event.relatedTarget isnt this - @dispatchEvent(new FocusEvent('blur', relatedTarget: event.relatedTarget, bubbles: false)) - - addGrammarScopeAttribute: -> - @dataset.grammar = @model.getGrammar()?.scopeName?.replace(/\./g, ' ') - - addMiniAttribute: -> - @setAttributeNode(document.createAttribute("mini")) - - removeMiniAttribute: -> - @removeAttribute("mini") - - addEncodingAttribute: -> - @dataset.encoding = @model.getEncoding() - - hasFocus: -> - this is document.activeElement or @contains(document.activeElement) - - setUpdatedSynchronously: (@updatedSynchronously) -> - @model?.setUpdatedSynchronously(@updatedSynchronously) - @updatedSynchronously - - isUpdatedSynchronously: -> @updatedSynchronously - - # Extended: Continuously reflows lines and line numbers. (Has performance overhead) - # - # * `continuousReflow` A {Boolean} indicating whether to keep reflowing or not. - setContinuousReflow: (continuousReflow) -> - @component?.setContinuousReflow(continuousReflow) - - # Extended: get the width of a character of text displayed in this element. - # - # Returns a {Number} of pixels. - getDefaultCharacterWidth: -> - @getModel().getDefaultCharWidth() - - # Extended: Get the maximum scroll top that can be applied to this element. - # - # Returns a {Number} of pixels. - getMaxScrollTop: -> - @component?.getMaxScrollTop() - - # Extended: Converts a buffer position to a pixel position. - # - # * `bufferPosition` An object that represents a buffer position. It can be either - # an {Object} (`{row, column}`), {Array} (`[row, column]`), or {Point} - # - # Returns an {Object} with two values: `top` and `left`, representing the pixel position. - pixelPositionForBufferPosition: (bufferPosition) -> - @component.pixelPositionForBufferPosition(bufferPosition) - - # Extended: Converts a screen position to a pixel position. - # - # * `screenPosition` An object that represents a screen position. It can be either - # an {Object} (`{row, column}`), {Array} (`[row, column]`), or {Point} - # - # Returns an {Object} with two values: `top` and `left`, representing the pixel positions. - pixelPositionForScreenPosition: (screenPosition) -> - @component.pixelPositionForScreenPosition(screenPosition) - - # Extended: Retrieves the number of the row that is visible and currently at the - # top of the editor. - # - # Returns a {Number}. - getFirstVisibleScreenRow: -> - @getVisibleRowRange()[0] - - # Extended: Retrieves the number of the row that is visible and currently at the - # bottom of the editor. - # - # Returns a {Number}. - getLastVisibleScreenRow: -> - @getVisibleRowRange()[1] - - # Extended: call the given `callback` when the editor is attached to the DOM. - # - # * `callback` {Function} - onDidAttach: (callback) -> - @emitter.on("did-attach", callback) - - # Extended: call the given `callback` when the editor is detached from the DOM. - # - # * `callback` {Function} - onDidDetach: (callback) -> - @emitter.on("did-detach", callback) - - onDidChangeScrollTop: (callback) -> - @emitter.on("did-change-scroll-top", callback) - - onDidChangeScrollLeft: (callback) -> - @emitter.on("did-change-scroll-left", callback) - - setScrollLeft: (scrollLeft) -> - @component.setScrollLeft(scrollLeft) - - setScrollRight: (scrollRight) -> - @component.setScrollRight(scrollRight) - - setScrollTop: (scrollTop) -> - @component.setScrollTop(scrollTop) - - setScrollBottom: (scrollBottom) -> - @component.setScrollBottom(scrollBottom) - - # Essential: Scrolls the editor to the top - scrollToTop: -> - @setScrollTop(0) - - # Essential: Scrolls the editor to the bottom - scrollToBottom: -> - @setScrollBottom(Infinity) - - getScrollTop: -> - @component?.getScrollTop() or 0 - - getScrollLeft: -> - @component?.getScrollLeft() or 0 - - getScrollRight: -> - @component?.getScrollRight() or 0 - - getScrollBottom: -> - @component?.getScrollBottom() or 0 - - getScrollHeight: -> - @component?.getScrollHeight() or 0 - - getScrollWidth: -> - @component?.getScrollWidth() or 0 - - getVerticalScrollbarWidth: -> - @component?.getVerticalScrollbarWidth() or 0 - - getHorizontalScrollbarHeight: -> - @component?.getHorizontalScrollbarHeight() or 0 - - getVisibleRowRange: -> - @component?.getVisibleRowRange() or [0, 0] - - intersectsVisibleRowRange: (startRow, endRow) -> - [visibleStart, visibleEnd] = @getVisibleRowRange() - not (endRow <= visibleStart or visibleEnd <= startRow) - - selectionIntersectsVisibleRowRange: (selection) -> - {start, end} = selection.getScreenRange() - @intersectsVisibleRowRange(start.row, end.row + 1) - - screenPositionForPixelPosition: (pixelPosition) -> - @component.screenPositionForPixelPosition(pixelPosition) - - pixelRectForScreenRange: (screenRange) -> - @component.pixelRectForScreenRange(screenRange) - - pixelRangeForScreenRange: (screenRange) -> - @component.pixelRangeForScreenRange(screenRange) - - setWidth: (width) -> - @style.width = (@component.getGutterWidth() + width) + "px" - - getWidth: -> - @offsetWidth - @component.getGutterWidth() - - setHeight: (height) -> - @style.height = height + "px" - - getHeight: -> - @offsetHeight - - # Experimental: Invalidate the passed block {Decoration} dimensions, forcing - # them to be recalculated and the surrounding content to be adjusted on the - # next animation frame. - # - # * {blockDecoration} A {Decoration} representing the block decoration you - # want to update the dimensions of. - invalidateBlockDecorationDimensions: -> - @component.invalidateBlockDecorationDimensions(arguments...) - -module.exports = TextEditorElement = document.registerElement 'atom-text-editor', prototype: TextEditorElement.prototype From 1b27df639d498c348efdbe887cf33c1a32512fc3 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 11 May 2017 16:08:26 +0200 Subject: [PATCH 374/403] :lipstick: --- src/text-editor-component.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 9ee5ec7bfca..ebaf611d8a8 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -3944,11 +3944,11 @@ function debounce (fn, wait) { timeout = setTimeout(later, wait - last) } else { timeout = null - result = fn() + fn() } } - return function() { + return function () { timestamp = Date.now() if (!timeout) timeout = setTimeout(later, wait) } From c0af383cc06274062f69808f8281818d50879424 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 11 May 2017 18:26:45 +0200 Subject: [PATCH 375/403] :arrow_up: electron --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8d078a34cea..d69a42c7d20 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/atom/atom/issues" }, "license": "MIT", - "electronVersion": "1.6.5", + "electronVersion": "1.6.9", "dependencies": { "async": "0.2.6", "atom-keymap": "8.1.2", From 25a2d5f42aedfcc5b0346a41679b81e3f3105c3a Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 11 May 2017 18:34:17 +0200 Subject: [PATCH 376/403] :arrow_up: text-buffer --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d69a42c7d20..0f4a1784baf 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "sinon": "1.17.4", "@atom/source-map-support": "^0.3.4", "temp": "^0.8.3", - "text-buffer": "12.1.2", + "text-buffer": "12.1.3", "typescript-simple": "1.0.0", "underscore-plus": "^1.6.6", "winreg": "^1.2.1", From 955d11b7cc6362862fa228d140f15896b8baa3de Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 11 May 2017 19:13:06 +0200 Subject: [PATCH 377/403] :lipstick: Fix more lint errors --- src/task-bootstrap.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/task-bootstrap.js b/src/task-bootstrap.js index 96563b8b177..a7076ebd49d 100644 --- a/src/task-bootstrap.js +++ b/src/task-bootstrap.js @@ -1,5 +1,3 @@ -/* global snapshotResult */ - const {userAgent} = process.env const [compileCachePath, taskPath] = process.argv.slice(2) From c59e972f425028a1484c7442589352e1cdd7b4b0 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 11 May 2017 19:18:40 +0200 Subject: [PATCH 378/403] Use buffer coordinates in isFoldedAtCursorRow This avoid an extra screen-to-buffer coordinate translation. Signed-off-by: Nathan Sobo --- src/text-editor.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/text-editor.coffee b/src/text-editor.coffee index a9676069a6e..a12f78b1683 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -3377,7 +3377,7 @@ class TextEditor extends Model # # Returns a {Boolean}. isFoldedAtCursorRow: -> - @isFoldedAtScreenRow(@getCursorScreenPosition().row) + @isFoldedAtBufferRow(@getCursorBufferPosition().row) # Extended: Determine whether the given row in buffer coordinates is folded. # From 2f2f481fb52df1e3c9576f03a6b1920f609a364d Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 11 May 2017 19:25:14 +0200 Subject: [PATCH 379/403] Don't clip screen range when autoscrolling from Cursor Signed-off-by: Nathan Sobo --- src/cursor.coffee | 3 ++- src/text-editor.coffee | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cursor.coffee b/src/cursor.coffee index 184e6ad43ab..74922ff5139 100644 --- a/src/cursor.coffee +++ b/src/cursor.coffee @@ -630,7 +630,8 @@ class Cursor extends Model {row, column} = @getScreenPosition() new Range(new Point(row, column), new Point(row, column + 1)) - autoscroll: (options) -> + autoscroll: (options = {}) -> + options.clip = false @editor.scrollToScreenRange(@getScreenRange(), options) getBeginningOfNextParagraphBufferPosition: -> diff --git a/src/text-editor.coffee b/src/text-editor.coffee index a12f78b1683..ab019fb11e9 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -3485,7 +3485,7 @@ class TextEditor extends Model @getElement().scrollToBottom() scrollToScreenRange: (screenRange, options = {}) -> - screenRange = @clipScreenRange(screenRange) + screenRange = @clipScreenRange(screenRange) if options.clip isnt false scrollEvent = {screenRange, options} @component?.didRequestAutoscroll(scrollEvent) @emitter.emit "did-request-autoscroll", scrollEvent From 8ff5d81384430efb7e9a82fbbfd37bf5352860d5 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 11 May 2017 20:12:10 +0200 Subject: [PATCH 380/403] Prevent unnecessary clipping of buffer range in isFoldedAtBufferRow Signed-off-by: Nathan Sobo --- src/text-editor.coffee | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/text-editor.coffee b/src/text-editor.coffee index ab019fb11e9..5680b22a75f 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -3385,7 +3385,11 @@ class TextEditor extends Model # # Returns a {Boolean}. isFoldedAtBufferRow: (bufferRow) -> - @displayLayer.foldsIntersectingBufferRange(Range(Point(bufferRow, 0), Point(bufferRow, Infinity))).length > 0 + range = Range( + Point(bufferRow, 0), + Point(bufferRow, @buffer.lineLengthForRow(bufferRow)) + ) + @displayLayer.foldsIntersectingBufferRange(range).length > 0 # Extended: Determine whether the given row in screen coordinates is folded. # From 354e0cad5553ab6c7e565cc40efcdadad5ea186e Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 11 May 2017 20:12:39 +0200 Subject: [PATCH 381/403] :arrow_up: first-mate --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0f4a1784baf..359dda74534 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "etch": "^0.12.4", "event-kit": "^2.3.0", "find-parent-dir": "^0.3.0", - "first-mate": "7.0.4", + "first-mate": "7.0.5", "fs-plus": "^3.0.0", "fstream": "0.1.24", "fuzzaldrin": "^2.1", From 80f033114c18c17cc1cb58eeac35560ddfb9ac99 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 11 May 2017 15:20:53 -0600 Subject: [PATCH 382/403] Mock Date.now in spec helper in addition to _.now Signed-off-by: Max Brunsfeld --- spec/spec-helper.coffee | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index 44d8b4460b1..685210c4f69 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -64,6 +64,7 @@ beforeEach -> atom.project.setPaths([specProjectPath]) window.resetTimeouts() + spyOn(Date, 'now').andCallFake -> window.now spyOn(_._, "now").andCallFake -> window.now spyOn(window, "setTimeout").andCallFake window.fakeSetTimeout spyOn(window, "clearTimeout").andCallFake window.fakeClearTimeout @@ -186,6 +187,8 @@ jasmine.useRealClock = -> jasmine.useMockClock = -> spyOn(window, 'setInterval').andCallFake(fakeSetInterval) spyOn(window, 'clearInterval').andCallFake(fakeClearInterval) + spyOn(Date, 'now').andCallFake(-> window.now) + addCustomMatchers = (spec) -> spec.addMatchers From f8ecf929a886f494e17c09310415ab169322a667 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 11 May 2017 16:46:02 -0600 Subject: [PATCH 383/403] =?UTF-8?q?Disable=20github=20package=20in=20smoke?= =?UTF-8?q?=20test=20=E2=80=93=20it=20takes=20too=20long=20to=20compile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spec/integration/smoke-spec.coffee | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/spec/integration/smoke-spec.coffee b/spec/integration/smoke-spec.coffee index dd689b47643..e147cf5c0fc 100644 --- a/spec/integration/smoke-spec.coffee +++ b/spec/integration/smoke-spec.coffee @@ -14,7 +14,10 @@ describe "Smoke Test", -> season.writeFileSync(path.join(atomHome, 'config.cson'), { '*': { welcome: {showOnStartup: false}, - core: {telemetryConsent: 'no'} + core: { + telemetryConsent: 'no', + disabledPackages: ['github'] + } } }) From d584bd6adb88cc456ef2a9a10841cefeabd9ffce Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 11 May 2017 16:58:48 -0600 Subject: [PATCH 384/403] Eliminate reliance on local storage in HistoryManager It's causing test failures locally and enough time has passed that most user data should be transitioned to indexed DB by now. --- spec/history-manager-spec.js | 5 ++--- src/atom-environment.coffee | 1 - src/history-manager.js | 8 -------- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/spec/history-manager-spec.js b/spec/history-manager-spec.js index 7e62a69f43c..7a06fce9bb9 100644 --- a/spec/history-manager-spec.js +++ b/spec/history-manager-spec.js @@ -31,7 +31,6 @@ describe("HistoryManager", () => { }) historyManager = new HistoryManager({stateStore, project, commands: commandRegistry}) - historyManager.initialize(window.localStorage) await historyManager.loadState() }) @@ -76,7 +75,7 @@ describe("HistoryManager", () => { it("saves the state", async () => { await historyManager.clearProjects() - const historyManager2 = new HistoryManager({stateStore, localStorage: window.localStorage, project, commands: commandRegistry}) + const historyManager2 = new HistoryManager({stateStore, project, commands: commandRegistry}) await historyManager2.loadState() expect(historyManager.getProjects().length).toBe(0) }) @@ -187,7 +186,7 @@ describe("HistoryManager", () => { it("saves the state", async () => { await historyManager.addProject(["/save/state"]) await historyManager.saveState() - const historyManager2 = new HistoryManager({stateStore, localStorage: window.localStorage, project, commands: commandRegistry}) + const historyManager2 = new HistoryManager({stateStore, project, commands: commandRegistry}) await historyManager2.loadState() expect(historyManager2.getProjects()[0].paths).toEqual(['/save/state']) }) diff --git a/src/atom-environment.coffee b/src/atom-environment.coffee index 3f3f0fbdb64..9117fe0ba75 100644 --- a/src/atom-environment.coffee +++ b/src/atom-environment.coffee @@ -257,7 +257,6 @@ class AtomEnvironment extends Model @observeAutoHideMenuBar() - @history.initialize(@window.localStorage) @disposables.add @applicationDelegate.onDidChangeHistoryManager(=> @history.loadState()) preloadPackages: -> diff --git a/src/history-manager.js b/src/history-manager.js index cd151e660cd..a8ddbaae94c 100644 --- a/src/history-manager.js +++ b/src/history-manager.js @@ -17,10 +17,6 @@ export class HistoryManager { this.disposables.add(project.onDidChangePaths((projectPaths) => this.addProject(projectPaths))) } - initialize (localStorage) { - this.localStorage = localStorage - } - destroy () { this.disposables.dispose() } @@ -98,10 +94,6 @@ export class HistoryManager { async loadState () { let history = await this.stateStore.load('history-manager') - if (!history) { - history = JSON.parse(this.localStorage.getItem('history')) - } - if (history && history.projects) { this.projects = history.projects.filter(p => Array.isArray(p.paths) && p.paths.length > 0).map(p => new HistoryProject(p.paths, new Date(p.lastOpened))) this.didChangeProjects({reloaded: true}) From 2e2b35c32c2f4b5eb83b5d6ae18e0316abe1a499 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 11 May 2017 17:44:16 -0600 Subject: [PATCH 385/403] Avoid problems related to calling `startEditorWindow` in tests --- spec/atom-environment-spec.coffee | 19 ++++++++++--------- src/atom-environment.coffee | 13 ++++++++----- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/spec/atom-environment-spec.coffee b/spec/atom-environment-spec.coffee index e856c1e1074..ed27b00c910 100644 --- a/spec/atom-environment-spec.coffee +++ b/spec/atom-environment-spec.coffee @@ -452,11 +452,14 @@ describe "AtomEnvironment", -> } atomEnvironment = new AtomEnvironment({applicationDelegate: atom.applicationDelegate}) atomEnvironment.initialize({window, document: fakeDocument}) - spyOn(atomEnvironment.packages, 'getAvailablePackagePaths').andReturn [] - spyOn(atomEnvironment, 'displayWindow').andReturn Promise.resolve() - atomEnvironment.startEditorWindow() - atomEnvironment.unloadEditorWindow() - atomEnvironment.destroy() + spyOn(atomEnvironment.packages, 'loadPackages').andReturn(Promise.resolve()) + spyOn(atomEnvironment.packages, 'activate').andReturn(Promise.resolve()) + spyOn(atomEnvironment, 'displayWindow').andReturn(Promise.resolve()) + waitsForPromise -> + atomEnvironment.startEditorWindow() + runs -> + atomEnvironment.unloadEditorWindow() + atomEnvironment.destroy() describe "::whenShellEnvironmentLoaded()", -> [atomEnvironment, envLoaded, spy] = [] @@ -471,21 +474,19 @@ describe "AtomEnvironment", -> applicationDelegate: atom.applicationDelegate updateProcessEnv: -> promise atomEnvironment.initialize({window, document}) - spyOn(atomEnvironment.packages, 'getAvailablePackagePaths').andReturn [] - spyOn(atomEnvironment, 'displayWindow').andReturn Promise.resolve() spy = jasmine.createSpy() - atomEnvironment.startEditorWindow() afterEach -> - atomEnvironment.unloadEditorWindow() atomEnvironment.destroy() it "is triggered once the shell environment is loaded", -> atomEnvironment.whenShellEnvironmentLoaded spy + atomEnvironment.updateProcessEnvAndTriggerHooks() envLoaded() runs -> expect(spy).toHaveBeenCalled() it "triggers the callback immediately if the shell environment is already loaded", -> + atomEnvironment.updateProcessEnvAndTriggerHooks() envLoaded() runs -> atomEnvironment.whenShellEnvironmentLoaded spy diff --git a/src/atom-environment.coffee b/src/atom-environment.coffee index 9117fe0ba75..88c200f93c3 100644 --- a/src/atom-environment.coffee +++ b/src/atom-environment.coffee @@ -680,11 +680,8 @@ class AtomEnvironment extends Model # Call this method when establishing a real application window. startEditorWindow: -> @unloaded = false - updateProcessEnvPromise = @updateProcessEnv(@getLoadSettings().env) - updateProcessEnvPromise.then => - @shellEnvironmentLoaded = true - @emitter.emit('loaded-shell-environment') - @packages.triggerActivationHook('core:loaded-shell-environment') + + updateProcessEnvPromise = @updateProcessEnvAndTriggerHooks() loadStatePromise = @loadState().then (state) => @windowDimensions = state?.windowDimensions @@ -808,6 +805,12 @@ class AtomEnvironment extends Model if styleElement.textContent.indexOf('scrollbar') >= 0 TextEditor.didUpdateScrollbarStyles() + updateProcessEnvAndTriggerHooks: -> + @updateProcessEnv(@getLoadSettings().env).then => + @shellEnvironmentLoaded = true + @emitter.emit('loaded-shell-environment') + @packages.triggerActivationHook('core:loaded-shell-environment') + ### Section: Messaging the User ### From 8743298c4c8a02e2ce2f33159b07c6807ab28d28 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 11 May 2017 17:59:40 -0600 Subject: [PATCH 386/403] Avoid polluting test with click events on the test runner We now pass an isolated document into the AtomEnvironment instance under test to avoid accidentally handling clicks on the test runner itself. --- spec/atom-environment-spec.coffee | 68 +++++++++++++++++++++---------- src/atom-environment.coffee | 2 +- 2 files changed, 48 insertions(+), 22 deletions(-) diff --git a/spec/atom-environment-spec.coffee b/spec/atom-environment-spec.coffee index ed27b00c910..a955d2cda89 100644 --- a/spec/atom-environment-spec.coffee +++ b/spec/atom-environment-spec.coffee @@ -221,44 +221,70 @@ describe "AtomEnvironment", -> atom.loadState().then (state) -> expect(state).toEqual(serializedState) it "saves state when the CPU is idle after a keydown or mousedown event", -> - spyOn(atom, 'saveState') + atomEnv = new AtomEnvironment({ + applicationDelegate: global.atom.applicationDelegate, + }) idleCallbacks = [] - spyOn(window, 'requestIdleCallback').andCallFake (callback) -> idleCallbacks.push(callback) + atomEnv.initialize({ + window: { + requestIdleCallback: (callback) -> idleCallbacks.push(callback), + addEventListener: () -> + removeEventListener: () -> + }, + document: document.implementation.createHTMLDocument() + }) + + spyOn(atomEnv, 'saveState') keydown = new KeyboardEvent('keydown') - atom.document.dispatchEvent(keydown) - advanceClock atom.saveStateDebounceInterval + atomEnv.document.dispatchEvent(keydown) + advanceClock atomEnv.saveStateDebounceInterval idleCallbacks.shift()() - expect(atom.saveState).toHaveBeenCalledWith({isUnloading: false}) - expect(atom.saveState).not.toHaveBeenCalledWith({isUnloading: true}) + expect(atomEnv.saveState).toHaveBeenCalledWith({isUnloading: false}) + expect(atomEnv.saveState).not.toHaveBeenCalledWith({isUnloading: true}) - atom.saveState.reset() + atomEnv.saveState.reset() mousedown = new MouseEvent('mousedown') - atom.document.dispatchEvent(mousedown) - advanceClock atom.saveStateDebounceInterval + atomEnv.document.dispatchEvent(mousedown) + advanceClock atomEnv.saveStateDebounceInterval idleCallbacks.shift()() - expect(atom.saveState).toHaveBeenCalledWith({isUnloading: false}) - expect(atom.saveState).not.toHaveBeenCalledWith({isUnloading: true}) + expect(atomEnv.saveState).toHaveBeenCalledWith({isUnloading: false}) + expect(atomEnv.saveState).not.toHaveBeenCalledWith({isUnloading: true}) + + atomEnv.destroy() it "ignores mousedown/keydown events happening after calling unloadEditorWindow", -> - spyOn(atom, 'saveState') + atomEnv = new AtomEnvironment({ + applicationDelegate: global.atom.applicationDelegate, + }) idleCallbacks = [] - spyOn(window, 'requestIdleCallback').andCallFake (callback) -> idleCallbacks.push(callback) + atomEnv.initialize({ + window: { + requestIdleCallback: (callback) -> idleCallbacks.push(callback), + addEventListener: () -> + removeEventListener: () -> + }, + document: document.implementation.createHTMLDocument() + }) + + spyOn(atomEnv, 'saveState') mousedown = new MouseEvent('mousedown') - atom.document.dispatchEvent(mousedown) - atom.unloadEditorWindow() - expect(atom.saveState).not.toHaveBeenCalled() + atomEnv.document.dispatchEvent(mousedown) + atomEnv.unloadEditorWindow() + expect(atomEnv.saveState).not.toHaveBeenCalled() - advanceClock atom.saveStateDebounceInterval + advanceClock atomEnv.saveStateDebounceInterval idleCallbacks.shift()() - expect(atom.saveState).not.toHaveBeenCalled() + expect(atomEnv.saveState).not.toHaveBeenCalled() mousedown = new MouseEvent('mousedown') - atom.document.dispatchEvent(mousedown) - advanceClock atom.saveStateDebounceInterval + atomEnv.document.dispatchEvent(mousedown) + advanceClock atomEnv.saveStateDebounceInterval idleCallbacks.shift()() - expect(atom.saveState).not.toHaveBeenCalled() + expect(atomEnv.saveState).not.toHaveBeenCalled() + + atomEnv.destroy() it "serializes the project state with all the options supplied in saveState", -> spyOn(atom.project, 'serialize').andReturn({foo: 42}) diff --git a/src/atom-environment.coffee b/src/atom-environment.coffee index 88c200f93c3..a283608d9f7 100644 --- a/src/atom-environment.coffee +++ b/src/atom-environment.coffee @@ -264,7 +264,7 @@ class AtomEnvironment extends Model attachSaveStateListeners: -> saveState = _.debounce((=> - window.requestIdleCallback => @saveState({isUnloading: false}) unless @unloaded + @window.requestIdleCallback => @saveState({isUnloading: false}) unless @unloaded ), @saveStateDebounceInterval) @document.addEventListener('mousedown', saveState, true) @document.addEventListener('keydown', saveState, true) From 6c6d38a3f06de9f3d2e530dc791c4ff4b8c853b9 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 11 May 2017 21:02:28 -0600 Subject: [PATCH 387/403] Destroy environment to silence leaked editor warning --- spec/atom-environment-spec.coffee | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spec/atom-environment-spec.coffee b/spec/atom-environment-spec.coffee index a955d2cda89..b318673a068 100644 --- a/spec/atom-environment-spec.coffee +++ b/spec/atom-environment-spec.coffee @@ -314,10 +314,13 @@ describe "AtomEnvironment", -> } ) }) + atom2.initialize({document, window}) atom2.deserialize(atom.serialize()) expect(atom2.textEditors.getGrammarOverride(editor)).toBe('text.plain') + atom2.destroy() + describe "openInitialEmptyEditorIfNecessary", -> describe "when there are no paths set", -> beforeEach -> From b7218e5aa4fc46068f58b83cd93e2e733c7b3f59 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 11 May 2017 21:06:15 -0600 Subject: [PATCH 388/403] Fix atom.setSize spec when window is full screen --- spec/atom-environment-spec.coffee | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/spec/atom-environment-spec.coffee b/spec/atom-environment-spec.coffee index b318673a068..dc2ab300a7e 100644 --- a/spec/atom-environment-spec.coffee +++ b/spec/atom-environment-spec.coffee @@ -29,10 +29,12 @@ describe "AtomEnvironment", -> atom.setSize(originalSize.width, originalSize.height) it 'sets the size of the window, and can retrieve the size just set', -> - newWidth = originalSize.width + 12 - newHeight = originalSize.height + 23 - atom.setSize(newWidth, newHeight) - expect(atom.getSize()).toEqual width: newWidth, height: newHeight + newWidth = originalSize.width - 12 + newHeight = originalSize.height - 23 + waitsForPromise -> + atom.setSize(newWidth, newHeight) + runs -> + expect(atom.getSize()).toEqual width: newWidth, height: newHeight describe ".isReleasedVersion()", -> it "returns false if the version is a SHA and true otherwise", -> From 5a9582ed64e634f437ea7a536feb7b76935de26f Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 11 May 2017 21:10:20 -0600 Subject: [PATCH 389/403] Silence error output in test --- spec/babel-spec.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/babel-spec.coffee b/spec/babel-spec.coffee index 4e7b2b3959b..070ad7a0b63 100644 --- a/spec/babel-spec.coffee +++ b/spec/babel-spec.coffee @@ -43,6 +43,7 @@ describe "Babel transpiler support", -> describe "when a .js file does not start with 'use babel';", -> it "does not transpile it using babel", -> + spyOn(console, 'error') expect(-> require('./fixtures/babel/invalid.js')).toThrow() it "does not try to log to stdout or stderr while parsing the file", -> From ffd154ec34854db8afefd249745d48d18762cb15 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 11 May 2017 21:29:36 -0600 Subject: [PATCH 390/403] Fix lint errors --- spec/atom-environment-spec.coffee | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/atom-environment-spec.coffee b/spec/atom-environment-spec.coffee index dc2ab300a7e..7bdc930380a 100644 --- a/spec/atom-environment-spec.coffee +++ b/spec/atom-environment-spec.coffee @@ -230,8 +230,8 @@ describe "AtomEnvironment", -> atomEnv.initialize({ window: { requestIdleCallback: (callback) -> idleCallbacks.push(callback), - addEventListener: () -> - removeEventListener: () -> + addEventListener: -> + removeEventListener: -> }, document: document.implementation.createHTMLDocument() }) @@ -263,8 +263,8 @@ describe "AtomEnvironment", -> atomEnv.initialize({ window: { requestIdleCallback: (callback) -> idleCallbacks.push(callback), - addEventListener: () -> - removeEventListener: () -> + addEventListener: -> + removeEventListener: -> }, document: document.implementation.createHTMLDocument() }) From e62d1b91b7628f4cef85e9f590fd7518f36ecc8a Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 11 May 2017 21:33:50 -0600 Subject: [PATCH 391/403] Unspy Date.now when jasmine.useRealClock is called --- spec/spec-helper.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index 685210c4f69..2379cc65081 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -180,6 +180,7 @@ jasmine.useRealClock = -> jasmine.unspy(window, 'setTimeout') jasmine.unspy(window, 'clearTimeout') jasmine.unspy(_._, 'now') + jasmine.unspy(Date, 'now') # The clock is halfway mocked now in a sad and terrible way... only setTimeout # and clearTimeout are included. This method will also include setInterval. We From 3c34e3f0370c240678bf428b73f279c47527c8f7 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 11 May 2017 21:46:26 -0600 Subject: [PATCH 392/403] Upgrade packages so tests pass with a mocked Date.now --- package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 359dda74534..ef7f63d3989 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ "encoding-selector": "0.23.3", "exception-reporting": "0.41.4", "find-and-replace": "0.208.1", - "fuzzy-finder": "1.5.6", + "fuzzy-finder": "1.5.7", "github": "0.0.6", "git-diff": "1.3.6", "go-to-line": "0.32.1", @@ -115,11 +115,11 @@ "line-ending-selector": "0.6.3", "link": "0.31.3", "markdown-preview": "0.159.12", - "metrics": "1.2.3", + "metrics": "1.2.4", "notifications": "0.67.1", "open-on-github": "1.2.1", "package-generator": "1.1.1", - "settings-view": "0.249.4", + "settings-view": "0.249.5", "snippets": "1.1.4", "spell-check": "0.71.4", "status-bar": "1.8.8", @@ -127,7 +127,7 @@ "symbols-view": "0.116.0", "tabs": "0.105.5", "timecop": "0.36.0", - "tree-view": "0.217.0-7", + "tree-view": "0.217.0-8", "update-package-dependencies": "0.11.0", "welcome": "0.36.3", "whitespace": "0.36.2", From 0d4e2b35569c279a0eb831dd4f04fbab2f4a8405 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 12 May 2017 13:04:03 +0200 Subject: [PATCH 393/403] Fix editor component tests on Windows --- spec/text-editor-component-spec.js | 44 +++++++++++++++++++----------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 66bbb6b971e..ef65a573828 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -446,7 +446,18 @@ describe('TextEditorComponent', () => { }) it('soft wraps lines based on the content width when soft wrap is enabled', async () => { - const {component, element, editor} = buildComponent({width: 435, attach: false}) + let baseCharacterWidth, gutterContainerWidth + { + const {component, editor} = buildComponent() + baseCharacterWidth = component.getBaseCharacterWidth() + gutterContainerWidth = component.getGutterContainerWidth() + editor.destroy() + } + + const {component, element, editor} = buildComponent({ + width: gutterContainerWidth + (baseCharacterWidth * 55), + attach: false + }) editor.setSoftWrapped(true) jasmine.attachToDOM(element) @@ -471,9 +482,12 @@ describe('TextEditorComponent', () => { }) it('accounts for the width of the vertical scrollbar when soft-wrapping lines', async () => { - const {component, element, editor} = buildComponent({height: 200, width: 200, attach: false, text: 'a'.repeat(300)}) - editor.setSoftWrapped(true) - jasmine.attachToDOM(element) + const {component, element, editor} = buildComponent({ + height: 200, + text: 'a'.repeat(300), + softWrapped: true + }) + await setEditorWidthInCharacters(component, 23) expect(Math.floor(component.getScrollContainerClientWidth() / component.getBaseCharacterWidth())).toBe(20) expect(editor.lineLengthForScreenRow(0)).toBe(20) }) @@ -871,16 +885,15 @@ describe('TextEditorComponent', () => { it('does not horizontally autoscroll by more than half of the visible "base-width" characters if the editor is narrower than twice the scroll margin', async () => { const {component, editor} = buildComponent({autoHeight: false}) await setEditorWidthInCharacters(component, 1.5 * editor.horizontalScrollMargin) - - const contentWidthInCharacters = Math.floor(component.getScrollContainerClientWidth() / component.getBaseCharacterWidth()) - expect(contentWidthInCharacters).toBe(9) + const editorWidthInChars = component.getScrollContainerWidth() / component.getBaseCharacterWidth() + expect(Math.round(editorWidthInChars)).toBe(9) editor.scrollToScreenRange([[6, 10], [6, 15]]) await component.getNextUpdatePromise() let expectedScrollLeft = Math.floor( clientLeftForCharacter(component, 6, 10) - lineNodeForScreenRow(component, 1).getBoundingClientRect().left - - (4 * component.getBaseCharacterWidth()) + Math.floor((editorWidthInChars - 1) / 2) * component.getBaseCharacterWidth() ) expect(component.getScrollLeft()).toBe(expectedScrollLeft) }) @@ -1105,10 +1118,8 @@ describe('TextEditorComponent', () => { describe('line and line number decorations', () => { it('adds decoration classes on screen lines spanned by decorated markers', async () => { - const {component, element, editor} = buildComponent({width: 435, attach: false}) - editor.setSoftWrapped(true) - jasmine.attachToDOM(element) - + const {component, element, editor} = buildComponent({softWrapped: true}) + await setEditorWidthInCharacters(component, 55) expect(lineNodeForScreenRow(component, 3).textContent).toBe( ' var pivot = items.shift(), current, left = [], ' ) @@ -2831,11 +2842,12 @@ describe('TextEditorComponent', () => { }) describe('on the scrollbars', () => { - it('delegates the mousedown events to the parent component unless the mousedown was on the actual scrollbar', () => { - const {component, element, editor} = buildComponent({height: 100, width: 100}) + it('delegates the mousedown events to the parent component unless the mousedown was on the actual scrollbar', async () => { + const {component, element, editor} = buildComponent({height: 100}) + await setEditorWidthInCharacters(component, 8.5) + const verticalScrollbar = component.refs.verticalScrollbar const horizontalScrollbar = component.refs.horizontalScrollbar - const leftEdgeOfVerticalScrollbar = verticalScrollbar.element.getBoundingClientRect().right - getVerticalScrollbarWidth(component) const topEdgeOfHorizontalScrollbar = horizontalScrollbar.element.getBoundingClientRect().bottom - getHorizontalScrollbarHeight(component) @@ -3350,7 +3362,7 @@ function buildEditor (params = {}) { const buffer = new TextBuffer({text}) const editorParams = {buffer} if (params.height != null) params.autoHeight = false - for (const paramName of ['mini', 'autoHeight', 'autoWidth', 'lineNumberGutterVisible', 'placeholderText']) { + for (const paramName of ['mini', 'autoHeight', 'autoWidth', 'lineNumberGutterVisible', 'placeholderText', 'softWrapped']) { if (params[paramName] != null) editorParams[paramName] = params[paramName] } return new TextEditor(editorParams) From 5848e9b2d08d1d63aac35cc125c3dbab5a54d403 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 12 May 2017 13:19:28 +0200 Subject: [PATCH 394/403] :arrow_up: first-mate --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ef7f63d3989..7c189fdb887 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "etch": "^0.12.4", "event-kit": "^2.3.0", "find-parent-dir": "^0.3.0", - "first-mate": "7.0.5", + "first-mate": "7.0.6", "fs-plus": "^3.0.0", "fstream": "0.1.24", "fuzzaldrin": "^2.1", From 96533998e2da5ee8f3adadc86efb48e9405ae6e5 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 12 May 2017 16:51:05 +0200 Subject: [PATCH 395/403] Fix editor element tests on Windows --- spec/text-editor-element-spec.js | 11 ++++++++++- src/text-editor-component.js | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/spec/text-editor-element-spec.js b/spec/text-editor-element-spec.js index cb778e3ed51..c92c6f14465 100644 --- a/spec/text-editor-element-spec.js +++ b/spec/text-editor-element-spec.js @@ -349,7 +349,16 @@ describe('TextEditorElement', () => { await element.getNextUpdatePromise() expect(element.getVisibleRowRange()).toEqual([4, 11]) - expect(element.pixelRectForScreenRange([[2, 3], [13, 11]])).toEqual({top: 34, left: 22, height: 204, width: 57}) + const top = 2 * editor.getLineHeightInPixels() + const bottom = 13 * editor.getLineHeightInPixels() + const left = Math.round(3 * editor.getDefaultCharWidth()) + const right = Math.round(11 * editor.getDefaultCharWidth()) + expect(element.pixelRectForScreenRange([[2, 3], [13, 11]])).toEqual({ + top, + left, + height: bottom + editor.getLineHeightInPixels() - top, + width: right - left + }) }) }) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index ebaf611d8a8..f0e90e7675d 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -2667,7 +2667,7 @@ class TextEditorComponent { getScrollLeftColumn () { if (this.hasInitialMeasurements && this.getLongestLineWidth() != null) { - return Math.floor(this.getScrollLeft() / this.getBaseCharacterWidth()) + return Math.round(this.getScrollLeft() / this.getBaseCharacterWidth()) } else { return this.pendingScrollLeftColumn || 0 } From 4c5127ca2fbbf5f62efd51bcbe2f6b9ce9d47b14 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 16 May 2017 14:05:11 +0200 Subject: [PATCH 396/403] Update foldable icon when a row's foldability changes --- spec/text-editor-component-spec.js | 15 +++++++++++++++ src/text-editor-component.js | 12 ++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index ef65a573828..bd03863ef60 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -195,6 +195,21 @@ describe('TextEditorComponent', () => { expect(lineNodeForScreenRow(component, 7)).toBe(lineNode) }) + it('updates lines numbers when a row\'s foldability changes (regression)', async () => { + const {component, element, editor} = buildComponent({text: 'abc\n'}) + editor.setCursorBufferPosition([1, 0]) + await component.getNextUpdatePromise() + expect(lineNumberNodeForScreenRow(component, 0).querySelector('.foldable')).toBeNull() + + editor.insertText(' def') + await component.getNextUpdatePromise() + expect(lineNumberNodeForScreenRow(component, 0).querySelector('.foldable')).toBeDefined() + + editor.undo() + await component.getNextUpdatePromise() + expect(lineNumberNodeForScreenRow(component, 0).querySelector('.foldable')).toBeNull() + }) + it('renders dummy vertical and horizontal scrollbars when content overflows', async () => { const {component, element, editor} = buildComponent({height: 100, width: 100}) const verticalScrollbar = component.refs.verticalScrollbar.element diff --git a/src/text-editor-component.js b/src/text-editor-component.js index f0e90e7675d..aeead049caa 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -823,11 +823,10 @@ class TextEditorComponent { const endRow = this.getRenderedEndRow() const renderedRowCount = this.getRenderedRowCount() - const {keys, softWrappedFlags, foldableFlags} = this.lineNumbersToRender const bufferRows = model.bufferRowsForScreenRows(startRow, endRow - 1) - this.lineNumbersToRender.bufferRows = bufferRows - keys.length = renderedRowCount - foldableFlags.length = renderedRowCount + const keys = new Array(endRow - startRow) + const foldableFlags = new Array(endRow - startRow) + const softWrappedFlags = new Array(endRow - startRow) let previousBufferRow = (startRow > 0) ? model.bufferRowForScreenRow(startRow - 1) : -1 let softWrapCount = 0 @@ -847,6 +846,11 @@ class TextEditorComponent { } previousBufferRow = bufferRow } + + this.lineNumbersToRender.bufferRows = bufferRows + this.lineNumbersToRender.keys = keys + this.lineNumbersToRender.foldableFlags = foldableFlags + this.lineNumbersToRender.softWrappedFlags = softWrappedFlags } queryMaxLineNumberDigits () { From ad6202cadbff75ba7ac8616692980764892b7953 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 16 May 2017 14:26:53 +0200 Subject: [PATCH 397/403] Show foldable icon on the last screen row belonging to a buffer row --- spec/text-editor-component-spec.js | 10 ++++++++++ src/text-editor-component.js | 15 ++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index bd03863ef60..e005e8f00db 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -210,6 +210,16 @@ describe('TextEditorComponent', () => { expect(lineNumberNodeForScreenRow(component, 0).querySelector('.foldable')).toBeNull() }) + it('shows the foldable icon on the last screen row of a buffer row that can be folded', async () => { + const {component, element, editor} = buildComponent({text: 'abc\n de\nfghijklm\n no', softWrapped: true}) + await setEditorWidthInCharacters(component, 5) + expect(lineNumberNodeForScreenRow(component, 0).classList.contains('foldable')).toBe(true) + expect(lineNumberNodeForScreenRow(component, 1).classList.contains('foldable')).toBe(false) + expect(lineNumberNodeForScreenRow(component, 2).classList.contains('foldable')).toBe(false) + expect(lineNumberNodeForScreenRow(component, 3).classList.contains('foldable')).toBe(true) + expect(lineNumberNodeForScreenRow(component, 4).classList.contains('foldable')).toBe(false) + }) + it('renders dummy vertical and horizontal scrollbars when content overflows', async () => { const {component, element, editor} = buildComponent({height: 100, width: 100}) const verticalScrollbar = component.refs.verticalScrollbar.element diff --git a/src/text-editor-component.js b/src/text-editor-component.js index aeead049caa..e834015b98b 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -823,7 +823,7 @@ class TextEditorComponent { const endRow = this.getRenderedEndRow() const renderedRowCount = this.getRenderedRowCount() - const bufferRows = model.bufferRowsForScreenRows(startRow, endRow - 1) + const bufferRows = model.bufferRowsForScreenRows(startRow, endRow) const keys = new Array(endRow - startRow) const foldableFlags = new Array(endRow - startRow) const softWrappedFlags = new Array(endRow - startRow) @@ -836,17 +836,26 @@ class TextEditorComponent { if (bufferRow === previousBufferRow) { softWrapCount++ softWrappedFlags[i] = true - foldableFlags[i] = false keys[i] = bufferRow + '-' + softWrapCount } else { softWrapCount = 0 softWrappedFlags[i] = false - foldableFlags[i] = model.isFoldableAtBufferRow(bufferRow) keys[i] = bufferRow } + + const nextBufferRow = bufferRows[i + 1] + if (bufferRow !== nextBufferRow) { + foldableFlags[i] = model.isFoldableAtBufferRow(bufferRow) + } else { + foldableFlags[i] = false + } + previousBufferRow = bufferRow } + // Delete extra buffer row at the end because it's not currently on screen. + bufferRows.pop() + this.lineNumbersToRender.bufferRows = bufferRows this.lineNumbersToRender.keys = keys this.lineNumbersToRender.foldableFlags = foldableFlags From 3b3505d969a512c6337111e8200952a7daaf2923 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 16 May 2017 14:36:50 +0200 Subject: [PATCH 398/403] Always allow to destroy free-form folds from the gutter --- spec/text-editor-component-spec.js | 11 ++++++++++- src/text-editor-component.js | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index e005e8f00db..0a7b28c7496 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -2798,7 +2798,7 @@ describe('TextEditorComponent', () => { it('toggles folding when clicking on the right icon of a foldable line number', async () => { const {component, element, editor} = buildComponent() - const target = element.querySelectorAll('.line-number')[1].querySelector('.icon-right') + let target = element.querySelectorAll('.line-number')[1].querySelector('.icon-right') expect(editor.isFoldedAtScreenRow(1)).toBe(false) component.didMouseDownOnLineNumberGutter({target, button: 0, clientY: clientTopForLine(component, 1)}) @@ -2806,7 +2806,16 @@ describe('TextEditorComponent', () => { await component.getNextUpdatePromise() component.didMouseDownOnLineNumberGutter({target, button: 0, clientY: clientTopForLine(component, 1)}) + await component.getNextUpdatePromise() expect(editor.isFoldedAtScreenRow(1)).toBe(false) + + editor.foldBufferRange([[5, 12], [5, 17]]) + await component.getNextUpdatePromise() + expect(editor.isFoldedAtScreenRow(5)).toBe(true) + + target = element.querySelectorAll('.line-number')[6].querySelector('.icon-right') + component.didMouseDownOnLineNumberGutter({target, button: 0, clientY: clientTopForLine(component, 5)}) + expect(editor.isFoldedAtScreenRow(5)).toBe(false) }) it('autoscrolls when dragging near the top or bottom of the gutter', async () => { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index e834015b98b..ca36a5f8119 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -1704,7 +1704,7 @@ class TextEditorComponent { const clickedScreenRow = this.screenPositionForMouseEvent(event).row const startBufferRow = model.bufferPositionForScreenPosition([clickedScreenRow, 0]).row - if (target && target.matches('.foldable .icon-right')) { + if (target && (target.matches('.foldable .icon-right') || target.matches('.folded .icon-right'))) { model.toggleFoldAtBufferRow(startBufferRow) return } From b15a59a3f838897fbd2eb640614be7fd36a5b7ce Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 16 May 2017 14:46:17 +0200 Subject: [PATCH 399/403] :arrow_up: text-buffer --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 61db7cac037..e98581c5e55 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "sinon": "1.17.4", "@atom/source-map-support": "^0.3.4", "temp": "^0.8.3", - "text-buffer": "12.1.3", + "text-buffer": "12.1.4", "typescript-simple": "1.0.0", "underscore-plus": "^1.6.6", "winreg": "^1.2.1", From a106a3baa0d74206ef3398267ee1049d64cc6906 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 16 May 2017 15:19:13 +0200 Subject: [PATCH 400/403] :art: --- src/text-editor-component.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index ca36a5f8119..c5a154eb6ef 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -824,9 +824,9 @@ class TextEditorComponent { const renderedRowCount = this.getRenderedRowCount() const bufferRows = model.bufferRowsForScreenRows(startRow, endRow) - const keys = new Array(endRow - startRow) - const foldableFlags = new Array(endRow - startRow) - const softWrappedFlags = new Array(endRow - startRow) + const keys = new Array(renderedRowCount) + const foldableFlags = new Array(renderedRowCount) + const softWrappedFlags = new Array(renderedRowCount) let previousBufferRow = (startRow > 0) ? model.bufferRowForScreenRow(startRow - 1) : -1 let softWrapCount = 0 From dad08f2b38d7c7b4513502bf76bd7d84c0824230 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 16 May 2017 15:23:14 +0200 Subject: [PATCH 401/403] :arrow_up: bracket-matcher --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e98581c5e55..2ee29115d21 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "autosave": "0.24.3", "background-tips": "0.27.0", "bookmarks": "0.44.4", - "bracket-matcher": "0.85.5", + "bracket-matcher": "0.85.6", "command-palette": "0.40.4", "dalek": "0.2.1", "deprecation-cop": "0.56.7", From 2c3500503f7cc8f21aba137afbf15d8c0bc82c0b Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 19 May 2017 10:23:13 +0200 Subject: [PATCH 402/403] Use custom snapshot row translation to prevent Electron 1.6 reload crash Adding a source map for the entire snapshot was expensive in terms of memory and seemed to be triggering some sort of bug in Chromium when reloading with the DevTools open. The custom row translation relies on a much more compact representation of the data and avoids the crash. Signed-off-by: Nathan Sobo --- src/compile-cache.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/compile-cache.js b/src/compile-cache.js index a8faeb11824..4209b30ab74 100644 --- a/src/compile-cache.js +++ b/src/compile-cache.js @@ -114,16 +114,19 @@ function writeCachedJavascript (relativeCachePath, code) { var INLINE_SOURCE_MAP_REGEXP = /\/\/[#@]\s*sourceMappingURL=([^'"\n]+)\s*$/mg -let snapshotSourceMapConsumer -if (global.isGeneratingSnapshot) { - // Warm up the source map consumer to efficiently translate positions when - // generating stack traces containing a file that was snapshotted. - const {SourceMapConsumer} = require('source-map') - snapshotSourceMapConsumer = new SourceMapConsumer(snapshotAuxiliaryData.sourceMap) // eslint-disable-line no-undef - snapshotSourceMapConsumer.originalPositionFor({line: 42, column: 0}) -} - exports.install = function (resourcesPath, nodeRequire) { + const snapshotSourceMapConsumer = { + originalPositionFor ({line, column}) { + const {relativePath, row} = snapshotResult.translateSnapshotRow(line) + return { + column, + line: row, + source: path.join(resourcesPath, 'app', 'static', relativePath), + name: null + } + } + } + sourceMapSupport.install({ handleUncaughtExceptions: false, @@ -132,10 +135,7 @@ exports.install = function (resourcesPath, nodeRequire) { // code from our cache directory. retrieveSourceMap: function (filePath) { if (filePath === '') { - return { - map: snapshotSourceMapConsumer, - url: path.join(resourcesPath, 'app', 'static', 'index.js') - } + return {map: snapshotSourceMapConsumer} } if (!cacheDirectory || !fs.isFileSync(filePath)) { From a7066e387ff3d8cab6baa86d76b3541606fba06a Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 19 May 2017 11:07:38 +0200 Subject: [PATCH 403/403] Fix lint errors Signed-off-by: Nathan Sobo --- package.json | 3 ++- src/main-process/main.js | 2 +- static/index.js | 20 ++++++++++---------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index a7223721994..f82dc7d8928 100644 --- a/package.json +++ b/package.json @@ -190,7 +190,8 @@ "IntersectionObserver", "FocusEvent", "requestAnimationFrame", - "HTMLElement" + "HTMLElement", + "snapshotResult" ] } } diff --git a/src/main-process/main.js b/src/main-process/main.js index 0beabdcba10..ee7b96232bc 100644 --- a/src/main-process/main.js +++ b/src/main-process/main.js @@ -1,5 +1,5 @@ if (typeof snapshotResult !== 'undefined') { - snapshotResult.setGlobals(global, process, global, {}, console, require) // eslint-disable-line no-undef + snapshotResult.setGlobals(global, process, global, {}, console, require) } const startTime = Date.now() diff --git a/static/index.js b/static/index.js index 064732470cc..7a751368371 100644 --- a/static/index.js +++ b/static/index.js @@ -42,21 +42,21 @@ if (process.platform === 'win32') { relativeFilePath = relativeFilePath.replace(/\\/g, '/') } - let cachedModule = snapshotResult.customRequire.cache[relativeFilePath] // eslint-disable-line no-undef + let cachedModule = snapshotResult.customRequire.cache[relativeFilePath] if (!cachedModule) { cachedModule = {exports: Module._load(module, this, false)} - snapshotResult.customRequire.cache[relativeFilePath] = cachedModule // eslint-disable-line no-undef + snapshotResult.customRequire.cache[relativeFilePath] = cachedModule } return cachedModule.exports } - snapshotResult.setGlobals(global, process, window, document, console, require) // eslint-disable-line no-undef + snapshotResult.setGlobals(global, process, window, document, console, require) } - const FileSystemBlobStore = useSnapshot ? snapshotResult.customRequire('../src/file-system-blob-store.js') : require('../src/file-system-blob-store') // eslint-disable-line no-undef + const FileSystemBlobStore = useSnapshot ? snapshotResult.customRequire('../src/file-system-blob-store.js') : require('../src/file-system-blob-store') blobStore = FileSystemBlobStore.load(path.join(process.env.ATOM_HOME, 'blob-store')) - const NativeCompileCache = useSnapshot ? snapshotResult.customRequire('../src/native-compile-cache.js') : require('../src/native-compile-cache') // eslint-disable-line no-undef + const NativeCompileCache = useSnapshot ? snapshotResult.customRequire('../src/native-compile-cache.js') : require('../src/native-compile-cache') NativeCompileCache.setCacheStore(blobStore) NativeCompileCache.setV8Version(process.versions.v8) NativeCompileCache.install() @@ -88,21 +88,21 @@ } function setupWindow () { - const CompileCache = useSnapshot ? snapshotResult.customRequire('../src/compile-cache.js') : require('../src/compile-cache') // eslint-disable-line no-undef + const CompileCache = useSnapshot ? snapshotResult.customRequire('../src/compile-cache.js') : require('../src/compile-cache') CompileCache.setAtomHomeDirectory(process.env.ATOM_HOME) CompileCache.install(process.resourcesPath, require) - const ModuleCache = useSnapshot ? snapshotResult.customRequire('../src/module-cache.js') : require('../src/module-cache') // eslint-disable-line no-undef + const ModuleCache = useSnapshot ? snapshotResult.customRequire('../src/module-cache.js') : require('../src/module-cache') ModuleCache.register(getWindowLoadSettings()) - const startCrashReporter = useSnapshot ? snapshotResult.customRequire('../src/crash-reporter-start.js') : require('../src/crash-reporter-start') // eslint-disable-line no-undef + const startCrashReporter = useSnapshot ? snapshotResult.customRequire('../src/crash-reporter-start.js') : require('../src/crash-reporter-start') startCrashReporter({_version: getWindowLoadSettings().appVersion}) - const CSON = useSnapshot ? snapshotResult.customRequire('../node_modules/season/lib/cson.js') : require('season') // eslint-disable-line no-undef + const CSON = useSnapshot ? snapshotResult.customRequire('../node_modules/season/lib/cson.js') : require('season') CSON.setCacheDir(path.join(CompileCache.getCacheDirectory(), 'cson')) const initScriptPath = path.relative(entryPointDirPath, getWindowLoadSettings().windowInitializationScript) - const initialize = useSnapshot ? snapshotResult.customRequire(initScriptPath) : require(initScriptPath) // eslint-disable-line no-undef + const initialize = useSnapshot ? snapshotResult.customRequire(initScriptPath) : require(initScriptPath) return initialize({blobStore: blobStore}).then(function () { electron.ipcRenderer.send('window-command', 'window:loaded') })