From f237d7035776d149ec8cd30377b555d247a82032 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 21 Feb 2017 17:32:27 -0700 Subject: [PATCH 001/306] 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 | 136 ++++---- static/text-editor.less | 50 +++ 8 files changed, 499 insertions(+), 71 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 000000000..7cd9cd2b3 --- /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 000000000..9de974d67 --- /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 f32109cbb..812c22749 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 caa1e1c6b..78bb8f2ea 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 8683a402c..f8d87270c 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--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; + // } - .editor-contents--private { - width: 100%; - cursor: text; - display: flex; - -webkit-user-select: none; - position: relative; - } + // .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; + // } - .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; - } - } - } + // .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 000000000..3ab026527 --- /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 002/306] 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 7cd9cd2b3..6cb8b9fac 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 3ab026527..586a727f5 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 003/306] 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 | 135 +++++++++++++++++++++-------------- static/text-editor.less | 3 +- 2 files changed, 83 insertions(+), 55 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 6cb8b9fac..1001e2114 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 props = { + ref: 'lineNumberGutter', + className: 'gutter line-numbers', + 'gutter-name': 'line-number' + } + let children - 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 + 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 + 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) - tileNodes.push($.div({ - style: { - height: tileHeight + 'px', - width: 'max-content', - willChange: 'transform', - transform: `translate3d(0, ${yTranslation}px, 0)`, - backgroundColor: 'inherit', - overflow: 'hidden' + 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 586a727f5..a95443449 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 004/306] 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 1001e2114..58749485b 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 005/306] 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 58749485b..aa9ad992e 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 006/306] 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 aa9ad992e..b9b08b8a8 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 a95443449..71482f5f5 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 007/306] Start on new TextEditorComponent specs; avoid excessive line numbers --- spec/text-editor-component-spec-old.js | 5128 +++++++++++++++++++++++ spec/text-editor-component-spec.js | 5206 +----------------------- src/text-editor-component.js | 87 +- src/text-editor-element.js | 11 +- 4 files changed, 5269 insertions(+), 5163 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 000000000..e145bac90 --- /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 1daeb34e9..2cebda10b 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 +const TextEditorComponent = require('../src/text-editor-component') +const TextEditor = require('../src/text-editor') +const fs = require('fs') +const path = require('path') -describe('TextEditorComponent', function () { - let charWidth, component, componentNode, contentNode, editor, - horizontalScrollbarNode, lineHeightInPixels, tileHeightInPixels, - verticalScrollbarNode, wrapperNode, animationFrameRequests +const SAMPLE_TEXT = fs.readFileSync(path.join(__dirname, 'fixtures', 'sample.js'), 'utf8') +const NBSP_CHARACTER = '\u00a0' - function runAnimationFrames (runFollowupFrames) { - if (runFollowupFrames) { - let fn - while (fn = animationFrameRequests.shift()) fn() - } else { - const requests = animationFrameRequests.slice() - animationFrameRequests = [] - for (let fn of requests) fn() - } - } +describe('TextEditorComponent', () => { + let editor - 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) + beforeEach(() => { + jasmine.useRealClock() + editor = new TextEditor() + editor.setText(SAMPLE_TEXT) }) - afterEach(function () { - contentNode.style.width = '' + 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) + ]) }) - - 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') - }) - }) - - 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 b9b08b8a8..8b372bf70 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 9de974d67..58fcc33b5 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 008/306] 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 2cebda10b..894a518ea 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 8b372bf70..9afd41a2e 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 009/306] 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 894a518ea..5432065b1 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 9afd41a2e..fa4773dc9 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 010/306] 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 5432065b1..e6bf8877b 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 fa4773dc9..89c09277d 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 011/306] 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 89c09277d..2b8b19db7 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 012/306] 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 2b8b19db7..f40f6ead6 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 013/306] Add cursor rendering tests --- spec/text-editor-component-spec.js | 73 +++++++++++++++ src/text-editor-component.js | 146 +++++++++++++++++++---------- src/text-editor.coffee | 2 +- 3 files changed, 173 insertions(+), 48 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index e6bf8877b..d8528a460 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 f40f6ead6..d3946dec6 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) - } - columns.push(position.column) - columns.push(position.column + 1) - } + const cursorMarkers = model.selectionsMarkerLayer.findMarkers({ + intersectsScreenRowRange: [ + this.getRenderedStartRow(), + this.getRenderedEndRow() - 1, + ] + }) - this.horizontalPositionsToMeasure.forEach((value) => value.sort((a, b) => a - b)) + 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 + } + } } - 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 812c22749..b7e078223 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 014/306] 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 d8528a460..b2a2ba8c3 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 015/306] 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 b2a2ba8c3..d8528a460 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 b7e078223..f66159a01 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 016/306] 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 d3946dec6..de5584ae9 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 017/306] 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 d8528a460..999779295 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 de5584ae9..a70d23821 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 f8d87270c..d688db3c0 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 018/306] 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 a70d23821..e1a10607e 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 019/306] 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 999779295..036d2ef1c 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 e1a10607e..a226d496e 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 020/306] 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 036d2ef1c..ba068fa75 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 a226d496e..b7f3231f0 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 021/306] 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 b7f3231f0..f0fc7eb1d 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 022/306] 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 f0fc7eb1d..78c3655f9 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 023/306] 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 78c3655f9..cbee96c94 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 024/306] 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 ba068fa75..9892a4c1e 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 cbee96c94..1b85025f5 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 025/306] 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 1b85025f5..610933556 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 026/306] 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 610933556..f0724a182 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 027/306] 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 f0724a182..cc96c9fc0 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 028/306] 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 cc96c9fc0..693208d0c 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() + const rowsPerTile = this.getRowsPerTile() - 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'}) - )) - - 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 029/306] 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 693208d0c..3a9e1cf42 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 030/306] 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 3a9e1cf42..8e00e5a1a 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 031/306] :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 8073c4aef..e1d4a4797 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 032/306] 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 f66159a01..737e33ed9 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 033/306] 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 8e00e5a1a..9551ef9d3 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 034/306] 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 9551ef9d3..acb91e4f4 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 035/306] 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 9892a4c1e..e6256a0bd 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 acb91e4f4..fbb176684 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 737e33ed9..6fa67400d 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 036/306] 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 e6256a0bd..2d959f0a4 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 037/306] 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 2d959f0a4..75422ba65 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 038/306] 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 e57660a57..ba5de0cf2 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 05935f018..000000000 --- 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 000000000..f9e31e60d --- /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 039/306] 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 f9e31e60d..dbaa68c8b 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 19d029f76..5d406e17c 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 040/306] 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 dbaa68c8b..95e49acf2 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 6fa67400d..aa0b05186 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 041/306] 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 95e49acf2..587df48ab 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 aa0b05186..2c42ecda8 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 042/306] 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 587df48ab..a1cb56f84 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 043/306] 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 a1cb56f84..0c5f1fec9 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 044/306] 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 0c5f1fec9..edc373929 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 045/306] 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 edc373929..16dd1f921 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 046/306] 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 16dd1f921..d00c8dded 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 047/306] Store decorations in sets instead of arrays --- src/decoration-manager.js | 53 +++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 30 deletions(-) diff --git a/src/decoration-manager.js b/src/decoration-manager.js index d00c8dded..489857a65 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.decorationsByMarker = new Map() - this.overlayDecorations = new Set() - this.layerDecorationsByMarkerLayer = new Map() this.decorationCountsByLayer = new Map() + this.decorationsByMarker = new Map() + this.layerDecorationsByMarkerLayer = 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 048/306] 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 75422ba65..db91ee991 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 fbb176684..3719ae75b 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 2c42ecda8..1e8ddcb52 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 71482f5f5..850907b67 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 049/306] 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 ba5de0cf2..ecef2bcc2 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 db91ee991..bc33382d7 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 489857a65..7a99d5809 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 fb544948f..03be59b14 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 3719ae75b..1f6db370f 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 1e8ddcb52..a94b6b0b1 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 050/306] 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 | 44 ++++++++++++++++------- src/text-editor.coffee | 12 ++++--- 3 files changed, 97 insertions(+), 17 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index bc33382d7..db82b5077 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 1f6db370f..06e3d5abd 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-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) + case 'line-number': + 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 + + 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 a94b6b0b1..540e1d2fd 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 051/306] 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 db82b5077..7e687300a 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 06e3d5abd..bd8857ee0 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 052/306] WIP: Add highlight decorations, but no tests yet --- src/text-editor-component.js | 298 ++++++++++++++++++++++++++++++----- 1 file changed, 261 insertions(+), 37 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index bd8857ee0..8494450fd 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 - - 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 - - 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) - } + this.addLineDecorationToRender(type, decoration, screenRange, reversed) + break + case 'highlight': + this.addHighlightDecorationToMeasure(decoration, screenRange) break } } } + 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 + + 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) + } + } + + 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 053/306] 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 8494450fd..ce923bbdb 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 540e1d2fd..b1d281abb 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 054/306] 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 ce923bbdb..e72ae4a65 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 055/306] 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 7e687300a..9c9bff93d 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 056/306] 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 9c9bff93d..e44dac272 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 e72ae4a65..491c82657 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 057/306] :arrow_up: etch --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e1d4a4797..72eacad34 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 058/306] 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 e44dac272..10043a185 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 491c82657..aae13af36 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 b1d281abb..4b1a510e2 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 059/306] 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 aae13af36..271c3e74f 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 060/306] 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 271c3e74f..315647aa8 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 061/306] 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 315647aa8..bd7dc2c94 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 062/306] 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 bd7dc2c94..9835e8782 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 063/306] 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 10043a185..cb86207ea 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 9835e8782..69e03c760 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 064/306] 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 cb86207ea..1c2286c9e 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 69e03c760..be3857b00 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 065/306] 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 1c2286c9e..f39e595ca 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 be3857b00..e8ea3c64c 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 4b1a510e2..c54ad0138 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 066/306] 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 f39e595ca..7f3978055 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 e8ea3c64c..a9515d474 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 067/306] 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 a9515d474..c3b6c83d0 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 068/306] 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 c3b6c83d0..5b25f3442 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 069/306] 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 5b25f3442..3619d3221 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 070/306] 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 7f3978055..54a7a0dc1 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 071/306] 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 54a7a0dc1..04366bfdb 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 072/306] 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 3619d3221..52e4f054e 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 c54ad0138..b826250f3 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 073/306] 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 04366bfdb..572c42f1f 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 52e4f054e..02e49ecba 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 074/306] 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 572c42f1f..0366c7415 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 02e49ecba..0a86629b0 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 075/306] 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 0366c7415..af10c304a 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 0a86629b0..ee115f087 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 076/306] 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 af10c304a..8e1452827 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 ee115f087..72342530c 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 077/306] Add spec structure --- spec/text-editor-component-spec.js | 690 +++++++++++++++-------------- src/text-editor-component.js | 1 - 2 files changed, 346 insertions(+), 345 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 8e1452827..df5a2287f 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 + 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, 14) + clientLeftForCharacter(component, 3, 16)) / 2 + 1, + clientY: clientTopForLine(component, 3) + lineHeight / 2 + }) + expect(editor.getCursorScreenPosition()).toEqual([3, 16]) }) - 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 + 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([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 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, 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('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]] + ]) + + // 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, 15]) - editor.getBuffer().setTextInRange([[3, 14], [3, 15]], '🐣') - await component.getNextUpdatePromise() + 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({ - detail: 1, - button: 0, - clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 16)) / 2, - clientY: clientTopForLine(component, 3) + lineHeight / 2 + 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, 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 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.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]] + ]) }) - 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('expands the last selection on shift-click', () => { + const {component, element, editor} = buildComponent() - 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]]) - }) - - 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), { + 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]]) - // 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), { + // 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, - metaKey: true - }) - ) - expect(editor.getCursorScreenPositions()).toEqual([[1, 16]]) + shiftKey: true + }, clientPositionForCharacter(component, 1, 4))) + expect(editor.getSelectedScreenRange()).toEqual([[1, 2], [2, 20]]) - // 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), { + component.didMouseDownOnContent(Object.assign({ detail: 1, button: 0, - metaKey: true - }) - ) - expect(editor.getSelectedScreenRanges()).toEqual([ - [[1, 16], [1, 16]] - ]) + shiftKey: true + }, clientPositionForCharacter(component, 3, 11))) + expect(editor.getSelectedScreenRange()).toEqual([[2, 14], [3, 13]]) - // ctrl-click does not add cursors on macOS - component.didMouseDownOnContent( - Object.assign(clientPositionForCharacter(component, 1, 4), { + // 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, - ctrlKey: true - }) - ) - expect(editor.getCursorScreenPositions()).toEqual([[1, 4]]) + shiftKey: true + }, clientPositionForCharacter(component, 1, 4))) + expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [3, 0]]) - mockedPlatform = 'win32' - - // 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]]) - }) + shiftKey: true + }, clientPositionForCharacter(component, 3, 11))) + expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [4, 0]]) + }) - 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 last selection on 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))) + + { + 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, - metaKey: true - }) - ) - expect(editor.getSelectedScreenRanges()).toEqual([ - [[0, 0], [0, 0]], - [[1, 13], [1, 21]] - ]) - }) + }, clientPositionForCharacter(component, 1, 4))) - 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.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]] - ]) - }) - - it('expands the last selection on shift-click', () => { - const {component, element, editor} = buildComponent() - - 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]]) - }) - - it('expands the last selection on drag', () => { - const {component, editor} = buildComponent() - spyOn(component, 'handleMouseDragUntilMouseUp') - - component.didMouseDownOnContent(Object.assign({ - detail: 1, - button: 0, - }, 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]] - ]) - } - }) + 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 word-wise on double-click-drag', () => { - const {component, editor} = buildComponent() - spyOn(component, 'handleMouseDragUntilMouseUp') + it('expands the selection line-wise on triple-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 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)) - 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]]) - }) + 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]]) + }) - it('expands the selection line-wise on triple-click-drag', () => { - const {component, editor} = buildComponent() - spyOn(component, 'handleMouseDragUntilMouseUp') + 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') - 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)) + let previousScrollTop = 0 + let previousScrollLeft = 0 + function assertScrolledDownAndRight () { + expect(scroller.scrollTop).toBeGreaterThan(previousScrollTop) + previousScrollTop = scroller.scrollTop + expect(scroller.scrollLeft).toBeGreaterThan(previousScrollLeft) + previousScrollLeft = scroller.scrollLeft + } - 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]]) + 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) + }) }) 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 72342530c..0a8cf4125 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 078/306] 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 df5a2287f..c532c1518 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 0a8cf4125..aef4b9b54 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 079/306] 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 c532c1518..cfd0e1afd 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 aef4b9b54..5b59d0d24 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 080/306] 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 cfd0e1afd..91a9e0bdf 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 7a99d5809..84278eb8f 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 081/306] 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 91a9e0bdf..685e81361 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 5b59d0d24..ed112a2be 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 082/306] 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 ed112a2be..6967f9500 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 083/306] 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 6967f9500..cb8b93e54 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 084/306] 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 cb8b93e54..ab5a17f35 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 085/306] 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 ab5a17f35..c24949b07 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 58fcc33b5..1a36b9782 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 086/306] 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 b826250f3..3fe1d40d0 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 087/306] 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 72eacad34..b5be71b30 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 685e81361..7e94ed810 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 c24949b07..0fbbd31a2 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 1a36b9782..38bedfe0e 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 088/306] 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 7e94ed810..942504131 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 0fbbd31a2..c8248dcce 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 089/306] 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 942504131..31498e4a0 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 c8248dcce..2deb4d803 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 090/306] 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 31498e4a0..8350251a9 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 2deb4d803..1c0511d70 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 091/306] 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 8350251a9..008b804f5 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 3fe1d40d0..4664168da 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 092/306] 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 008b804f5..1fcb8b3c7 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 1c0511d70..45a1092f4 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 093/306] 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 1fcb8b3c7..c8120757b 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 094/306] 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 c8120757b..e4f996bdf 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 45a1092f4..aa24cbd6a 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 095/306] 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 e4f996bdf..230cfd399 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 aa24cbd6a..547c28861 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 096/306] 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 230cfd399..a842c2161 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 547c28861..8ea01dce7 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 097/306] 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 a842c2161..8243a8747 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 8ea01dce7..413f5a94b 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 098/306] 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 8243a8747..8cab5b645 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 5d406e17c..7be15d9f5 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 413f5a94b..7f8a97597 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 099/306] Refactor highlight flashing --- src/text-editor-component.js | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 7f8a97597..52688ab46 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 + if (!this.timeoutsByClassName) this.timeoutsByClassName = new Map() - const addAndRemoveFlashClass = () => { + // 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 100/306] 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 8cab5b645..9967901ce 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 84278eb8f..ec8b8b684 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 03be59b14..e20921b4d 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 101/306] 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 52688ab46..5e75a09ee 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 102/306] 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 5e75a09ee..5c11102db 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 103/306] 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 9967901ce..4eecdac61 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 104/306] 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 5c11102db..3b09ba7d1 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 105/306] 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 4eecdac61..6bc8cb363 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 106/306] 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 | 202 +++++------ src/text-editor-component.js | 524 ++++++++++++++--------------- src/text-editor-element.js | 2 +- 3 files changed, 351 insertions(+), 377 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 6bc8cb363..e96d5ecd9 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) + expect(component.getScrollTop()).toBe(0) + expect(component.getScrollLeft()).toBe(0) - const maxScrollTop = scroller.scrollHeight - scroller.clientHeight - const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth - scroller.scrollTop = maxScrollTop - scroller.scrollLeft = maxScrollLeft + 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) + expect(component.getScrollTop()).toBe(0) + expect(component.getScrollLeft()).toBe(0) - const maxScrollTop = scroller.scrollHeight - scroller.clientHeight - const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth - scroller.scrollTop = maxScrollTop - scroller.scrollLeft = maxScrollLeft + 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 3b09ba7d1..1016dcb7c 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 - } - - getClientWidth () { - return this.measurements.clientWidth - } - - getGutterContainerWidth () { - return this.measurements.lineNumberGutterWidth + getContentHeight () { + return this.props.model.getApproximateScreenLineCount() * this.getLineHeight() } 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() - ) - } + return Math.round(this.getLongestLineWidth() + this.getBaseCharacterWidth()) + } + + getGutterContainerWidth () { + return this.getLineNumberGutterWidth() + } + + 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 38bedfe0e..eb64e5fa7 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 107/306] 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 | 262 +++++++++++++++++++++++++---- src/text-editor.coffee | 6 +- 4 files changed, 247 insertions(+), 35 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index e96d5ecd9..c209ce6ef 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 8ed491c15..7167cc36a 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 1016dcb7c..ff199d491 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() + if (this.pendingAutoscroll) { + this.autoscrollHorizontally() + if (!wasHorizontalScrollbarVisible && this.isHorizontalScrollbarVisible()) { + this.autoscrollVertically() + } + this.pendingAutoscroll = null + } + this.scrollbarsVisible = true etch.updateSync(this) - if (this.pendingAutoscroll) { - this.autoscrollHorizontally() - this.pendingAutoscroll = null - } 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 4664168da..442858434 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 108/306] Group rendering tests --- spec/text-editor-component-spec.js | 388 +++++++++++++++-------------- 1 file changed, 195 insertions(+), 193 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index c209ce6ef..d6c4bd7c8 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}) + describe('rendering', () => { + 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) + 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) + 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() + 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) - ]) + // 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) - ]) - }) + 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) + ]) + }) - 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', () => { + 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') + 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 - }) + // 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({autoHeight: false, autoWidth: false}) - const {scrollContainer} = component.refs + 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 - await editor.update({scrollPastEnd: true}) - await setEditorHeightInLines(component, 6) + await editor.update({scrollPastEnd: true}) + await setEditorHeightInLines(component, 6) - // scroll to end - component.setScrollTop(scrollContainer.scrollHeight - scrollContainer.clientHeight) - await component.getNextUpdatePromise() - expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() - 3) + // scroll to end + 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 - expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() - 6) + editor.update({scrollPastEnd: false}) + await component.getNextUpdatePromise() // wait for scrollable content resize + 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}) - component.setScrollTop(scrollContainer.scrollHeight - scrollContainer.clientHeight) - await component.getNextUpdatePromise() - expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() + 1) - }) + // 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('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('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') + 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') - }) + // 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('renders cursors within the visible row range', async () => { - const {component, element, editor} = buildComponent({height: 40, rowsPerTile: 2}) - component.setScrollTop(100) - 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(component.getRenderedStartRow()).toBe(4) - expect(component.getRenderedEndRow()).toBe(10) + 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() + 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) + 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() + 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) + 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() + editor.setCursorScreenPosition([0, 0], {autoscroll: false}) + await component.getNextUpdatePromise() - cursorNodes = Array.from(element.querySelectorAll('.cursor')) - expect(cursorNodes.length).toBe(0) + 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) - }) + 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() + 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) + 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) + // 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)) - }) + // 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) + 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 = [];' - ) + 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 = [];' - ) + 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 {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('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() + 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) - }) + 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('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 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) + 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 109/306] :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 d6c4bd7c8..79f9501ab 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 110/306] 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 79f9501ab..841008b37 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 ff199d491..dc3f7dd66 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 111/306] 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 841008b37..b1e1a8a25 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 dc3f7dd66..b89027d3c 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 112/306] 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 b1e1a8a25..2c8d6253d 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 b89027d3c..d96749717 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 113/306] 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 d96749717..bec17d30c 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 114/306] 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 2c8d6253d..21cabb6ea 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 bec17d30c..627b323d8 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 115/306] 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 21cabb6ea..161c843a2 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 627b323d8..ccaffc0bc 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 116/306] 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 161c843a2..e1d2e0030 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 117/306] 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 e1d2e0030..de20d16e9 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 ccaffc0bc..770af9afb 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 118/306] 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 a70ca54b7..ccf88cc9f 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 a8f1aafe6..2d9e724b2 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 e87586374..a5fdc43d6 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 770af9afb..0d5cb0811 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 442858434..33bfcbf6f 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 119/306] 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 7167cc36a..1a6dd6cbe 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 0d5cb0811..4be22d9b2 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 33bfcbf6f..9c4c0ccd1 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 120/306] 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 4be22d9b2..ce6efe21e 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 121/306] 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 ce6efe21e..fb5cc6b97 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 122/306] 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 de20d16e9..c532f559d 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 123/306] 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 fb5cc6b97..b85e30f9b 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 124/306] 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 b85e30f9b..f72d90175 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 125/306] 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 c532f559d..7f240f958 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 f72d90175..760587929 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 126/306] 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 7f240f958..5e6fbf615 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 084e1e1ad..743508355 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 64535efa4..1001fdfa2 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 760587929..d5781e876 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 9c4c0ccd1..49fb920e2 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 127/306] :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 b5be71b30..131392122 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 5e6fbf615..14f2c14c9 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 ec8b8b684..fc3692bce 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 1001fdfa2..19792ff12 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 ccf88cc9f..25b990110 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 2d9e724b2..166319b94 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 a5fdc43d6..5ad10670a 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 d5781e876..91889e7c1 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 49fb920e2..2cd30944c 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 d688db3c0..493696aca 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 128/306] 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 14f2c14c9..d8d80ecec 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 91889e7c1..29eef8f3e 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 129/306] 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 | 297 +++++++++++++++-------------- src/text-editor.coffee | 4 +- 3 files changed, 188 insertions(+), 164 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index d8d80ecec..2e1b66e16 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 29eef8f3e..a895997a1 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 decorationsForRow = decorations.get(row) + if (decorationsForRow) className += ' ' + decorationsForRow + + if (number === -1) number = '•' + number = NBSP_CHARACTER.repeat(maxDigits - number.length) + number + + tileChildren[row - tileStartRow] = $.div({key, className}, + number, + $.div({className: 'icon-right'}) + ) } - const lineNumberDecoration = lineNumberDecorations[i] - if (lineNumberDecoration != null) className += ' ' + lineNumberDecoration + const tileIndex = parentComponent.tileIndexForTileStartRow(tileStartRow) + const top = tileStartRow * lineHeight - lineNumber = NBSP_CHARACTER.repeat(maxLineNumberDigits - lineNumber.length) + lineNumber - - tileChildren[row - tileStartRow] = $.div({key, className}, - lineNumber, - $.div({className: 'icon-right'}) - ) + 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) } - - 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) } 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 2cd30944c..f2c0ab92f 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 130/306] 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 2e1b66e16..4e45918ef 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 a895997a1..7437c3225 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 131/306] 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 7437c3225..0f28d8cfb 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 132/306] Cache subtrees to avoid duplicating work within a single frame --- src/text-editor-component.js | 257 ++++++++++++++++++++--------------- 1 file changed, 146 insertions(+), 111 deletions(-) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 0f28d8cfb..6949e2415 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 + const {measuredContent, 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 + 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 133/306] 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 6949e2415..dab6166f2 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 134/306] 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 4e45918ef..95c2eb4fe 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 135/306] :arrow_up: etch --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 131392122..ea3967d36 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 136/306] 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 dab6166f2..5cf739334 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 137/306] 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 5cf739334..27e14191f 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 138/306] 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 95c2eb4fe..1d717f753 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 27e14191f..17562e47f 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 139/306] 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 17562e47f..e35fb9733 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 140/306] 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 e35fb9733..9dc738d08 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 141/306] 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 1d717f753..5866ea947 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 9dc738d08..fd23c72ea 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 142/306] 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 fd23c72ea..c30f668a9 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 143/306] 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 5866ea947..d18ba5213 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 c30f668a9..a49e7fd30 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 144/306] 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 29d2fdf86..614cb01a9 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 145/306] 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 d18ba5213..2a8202479 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 a49e7fd30..daec20981 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 146/306] 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 2a8202479..5db1f75f4 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 daec20981..ff013034e 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 147/306] 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 ff013034e..d0d6cd367 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 148/306] 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 d0d6cd367..6381123ad 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 149/306] 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 5db1f75f4..44789a6d6 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(0).offsetHeight).toBe( - 3 * component.getLineHeight() + item1.offsetHeight + item2.offsetHeight + expect(tileNodeForScreenRow(component, 0).offsetHeight).toBe( + 3 * component.getLineHeight() + getElementHeight(item1) + getElementHeight(item2) ) + 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 6381123ad..ae640a75f 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 150/306] 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 44789a6d6..b48d1f612 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 151/306] 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 b48d1f612..bc826995e 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 fc3692bce..a37eafe7f 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 ae640a75f..940069e57 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 152/306] 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 ea3967d36..76cd869c0 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 bc826995e..0537b78f7 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 940069e57..f88dcb7de 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 153/306] 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 f88dcb7de..28562fb7f 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 154/306] 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 0537b78f7..4ed60c1ab 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 28562fb7f..59593b882 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) } - rowDecorations.push(decoration) + + let decorations = decorationsByScreenLine.get(screenLine.id) + if (!decorations) { + decorations = [] + decorationsByScreenLine.set(screenLine.id, decorations) + } + 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 155/306] 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 4ed60c1ab..c7cedb863 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 59593b882..e2451de1f 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 156/306] 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 c7cedb863..d00400dc0 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 e2451de1f..7ba75573f 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 eb64e5fa7..b1ea45dd2 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 157/306] 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 d00400dc0..398ad7155 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 47e8c0594..184e6ad43 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 8aa86157e..935a15b13 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 7ba75573f..c87958f98 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 f2c0ab92f..bf3979a36 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 158/306] 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 398ad7155..80269f9c9 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 c87958f98..1915f93be 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 159/306] 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 80269f9c9..9cc66fa20 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 1915f93be..dbfb9f198 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 160/306] 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 b1ea45dd2..23a5aeb6e 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 161/306] 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 9cc66fa20..a23cb5e00 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 1a6dd6cbe..06d5331ff 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 dbfb9f198..9d51bbd40 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 bf3979a36..7196e2118 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 162/306] 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 9d51bbd40..183a50ed6 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 163/306] 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 a23cb5e00..8f0e154a3 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 164/306] 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 8f0e154a3..980060734 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 183a50ed6..925ee8722 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 165/306] 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 980060734..0937da810 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 166/306] 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 0937da810..e7bd634b3 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 925ee8722..fd90dbaeb 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 167/306] 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 fd90dbaeb..54ac5437c 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 168/306] 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 54ac5437c..42b10ba39 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 169/306] 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 42b10ba39..55eb28584 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 170/306] 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 55eb28584..b4245a8cd 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 171/306] 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 e7bd634b3..818eb78f4 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 172/306] 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 166319b94..7ba99c468 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 173/306] 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 850907b67..90164d040 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 174/306] 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 76cd869c0..60b447ce8 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 a37eafe7f..7a9269cae 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 19792ff12..6b39398dd 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 b4245a8cd..994dfdb7f 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 23a5aeb6e..f53c69635 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 614cb01a9..540b4ad3a 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 175/306] 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 994dfdb7f..0b5a78d8a 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 176/306] 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 527ed1f8f..dd689b476 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 177/306] 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 30748b787..47c5983f6 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 178/306] 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 c650184e2..4bae1d811 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 179/306] 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 0b5a78d8a..cd8ebcb77 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 180/306] 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 9b7d1d673..1d50e0b79 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 7a9269cae..06dd3f2f5 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 181/306] 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 e35907e65..ea597c0aa 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 06d5331ff..7ecb9dd44 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 182/306] 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 cd8ebcb77..d25483d23 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 183/306] 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 731e7bfeb..93b541302 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 b62485cad..b09bf009a 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 68fd74804..61e09335e 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 2c4b6dbab..2b382b938 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 184/306] 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 818eb78f4..2d58a291d 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 d25483d23..60c1e2ea2 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 185/306] 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 2d58a291d..5020e82b8 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 60c1e2ea2..15ec36541 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 186/306] 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 f53c69635..2a0f46496 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 187/306] 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 15ec36541..61228036e 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 188/306] 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 61228036e..e47b3e0ae 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 189/306] 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 60b447ce8..b05837050 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 e47b3e0ae..aeb2d576f 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 190/306] 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 5020e82b8..5123c2db0 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 191/306] 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 90164d040..ab53762fb 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 192/306] 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 5123c2db0..98ecbfaf0 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 aeb2d576f..62c6a704b 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 193/306] 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 98ecbfaf0..f4324505b 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 62c6a704b..1a58d1241 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 194/306] 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 1a58d1241..da50885c4 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 195/306] 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 f4324505b..400af0a42 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 da50885c4..ded80019a 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 196/306] 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 400af0a42..b77fedb45 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 ded80019a..73fec8f7e 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 197/306] 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 93b541302..000000000 --- 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 c3396ff9f..000000000 --- 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 b09bf009a..000000000 --- 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 61e09335e..000000000 --- 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 e145bac90..000000000 --- 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 2b382b938..000000000 --- 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 2de4f1ede..000000000 --- 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 759fcf2c4..000000000 --- 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 ebb2d8597..000000000 --- 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 e5c1db60e..000000000 --- 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 27543a2fd..000000000 --- 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 f0f150ff2..000000000 --- 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 ba62af3f8..000000000 --- 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 a30287714..000000000 --- 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 9bfdf7382..000000000 --- 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 308cc5af0..000000000 --- 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 ffb92c0ab..000000000 --- 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 0460c854e..000000000 --- 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 9d418e8c6..000000000 --- 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 bc059f12c..000000000 --- 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 e38030b91..000000000 --- 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 1106cee09..000000000 --- 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 198/306] 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 b05837050..6eb57d2fe 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 867599c55..a64d1ac20 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 73fec8f7e..b601a4d35 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 2a0f46496..9cf995ee9 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 7196e2118..ac0a05ca5 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 199/306] 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 862b6d4ac..2a70893ca 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 b601a4d35..14f4e2d52 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 9cf995ee9..35f0715ed 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 200/306] 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 14f4e2d52..f9d8bbace 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 201/306] 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 35f0715ed..ee193d5a6 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 202/306] 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 a64d1ac20..000000000 --- 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 000000000..7d1d1d175 --- /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 203/306] 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 7d1d1d175..ee1ef37cd 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 ee193d5a6..442d7e021 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 204/306] 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 b77fedb45..ac8b34492 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 f9d8bbace..d1327a09e 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 442d7e021..fa387527c 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 205/306] 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 fa387527c..fe780f7db 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 206/306] 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 d1327a09e..0385b3253 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 207/306] 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 ac8b34492..ad3b7dd20 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 0385b3253..dd4d2f640 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 208/306] 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 2b8260db6..d6b2c0d2d 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 209/306] 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 ad3b7dd20..1f4689752 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 dd4d2f640..b40a02bae 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 ac0a05ca5..1b374404a 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 210/306] 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 b40a02bae..01e046740 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 211/306] 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 ee1ef37cd..294973c3e 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 fe780f7db..9bbfe675b 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 212/306] 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 01e046740..7c17c046c 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 213/306] 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 7c17c046c..30dbce515 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 214/306] 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 1f4689752..506d7223f 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 9bbfe675b..d4f402c22 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 1b374404a..12ae6e9f1 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 215/306] 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 506d7223f..e978e0328 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 30dbce515..eefff77a9 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 216/306] 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 e978e0328..d920750fd 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 12ae6e9f1..5744c3cd7 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 217/306] 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 d920750fd..5a7fccffb 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 eefff77a9..369395d7f 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 5744c3cd7..21d80135d 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 218/306] 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 5a7fccffb..def4d68e7 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 369395d7f..c57e58715 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 21d80135d..31673736b 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 219/306] 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 c57e58715..e896989af 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 220/306] 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 1d50e0b79..96d897fb9 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 221/306] 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 06990bad5..1839f1c59 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 222/306] 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 294973c3e..686875f04 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 223/306] Don't update synchronously in text-editor-element-spec --- spec/text-editor-element-spec.js | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/spec/text-editor-element-spec.js b/spec/text-editor-element-spec.js index 686875f04..26f0328b2 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().setText('lorem\nipsum\ndolor\nsit\namet') - element.setUpdatedSynchronously(true) - element.setHeight(20) - element.setWidth(20) 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() }) 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 224/306] 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 26f0328b2..114c188d5 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 d4f402c22..e4b9e5628 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 225/306] 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 ab53762fb..ed3798d40 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 226/306] 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 114c188d5..6ea677152 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 e4b9e5628..f9f270c2d 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 227/306] 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 6ea677152..4a6655714 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 228/306] 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 def4d68e7..5175b895d 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 e896989af..28d50443d 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 229/306] 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 31673736b..a97732e96 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 230/306] 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 5175b895d..79f2cf7ff 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 96d897fb9..95a79f59e 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 28d50443d..322bfbec8 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 a97732e96..a2ba2dc85 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 231/306] 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 95a79f59e..3c2afc6ab 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 f9f270c2d..559910506 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 232/306] 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 79f2cf7ff..a63921c91 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 322bfbec8..a8367d23a 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 233/306] 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 a8367d23a..cb7c22311 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 234/306] 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 559910506..f87ca0845 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 235/306] 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 a63921c91..832456b27 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 06dd3f2f5..e98731623 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 236/306] 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 832456b27..3f9282819 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 cb7c22311..dd622939c 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 237/306] 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 3f9282819..1f2f05d6c 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 dd622939c..bdcc88b9b 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 238/306] 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 1f2f05d6c..9bb8bd646 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 bdcc88b9b..5f66f5f51 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 239/306] 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 9bb8bd646..bf9c20c6e 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 5f66f5f51..0990d4c20 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 240/306] 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 bf9c20c6e..2d5c88edc 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 0990d4c20..88b52ddcc 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 241/306] 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 2d5c88edc..31db65760 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 88b52ddcc..46eb9b164 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 - this.requestHorizontalMeasurement(row, column) - let columnWidth = 0 - if (model.lineLengthForScreenRow(row) > column) { - columnWidth = 1 - this.requestHorizontalMeasurement(row, column + 1) + 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) + } + 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 a2ba2dc85..5330a564c 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 242/306] 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 31db65760..d832ab606 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 46eb9b164..6bed15a52 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 f87ca0845..428c44ee5 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 243/306] 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 d832ab606..0f7164b9b 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 6bed15a52..829d319ae 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 244/306] 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 78bb8f2ea..14e7def8f 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 493696aca..000000000 --- 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 ed3798d40..ae0f564fa 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 245/306] 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 ae0f564fa..320a780f3 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 246/306] 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 829d319ae..0bcce166d 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 247/306] 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 | 93 +++++++++++++---------------- src/text-editor.coffee | 14 ++++- 5 files changed, 68 insertions(+), 61 deletions(-) diff --git a/spec/decoration-manager-spec.coffee b/spec/decoration-manager-spec.coffee index ecef2bcc2..76bc37b75 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 e98731623..cf3301fd4 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 743508355..677fa4521 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 0bcce166d..6eccaaa37 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 + + 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) } - - 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) { + observeBlockDecorations () { + const {model} = this.props + const decorations = model.getDecorations({type: 'block'}) + for (let i = 0; i < decorations.length; i++) { + this.didAddBlockDecoration(decorations[i]) + } + } + + 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 5330a564c..9db3df32f 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 248/306] 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 6eccaaa37..b175a407d 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 249/306] 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 e38367835..dc4af0b8c 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 250/306] 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 0f7164b9b..0588aafa0 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 b175a407d..d13259b96 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 251/306] 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 0588aafa0..e5ec8d98a 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 d13259b96..bde57efaf 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 252/306] 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 428c44ee5..e9c0b687f 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 253/306] 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 9db3df32f..4864663fe 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 254/306] 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 bde57efaf..8d1d4bb99 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 255/306] 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 0b54c6ea1..5cbfadef6 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 320a780f3..9acdb90da 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 256/306] 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 e5ec8d98a..f78197a2b 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 8d1d4bb99..24be53267 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 257/306] 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 f78197a2b..b0601f311 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 24be53267..bb1ee2e9d 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 258/306] 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 bb1ee2e9d..afbacca82 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 259/306] 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 afbacca82..36d8ec45a 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 260/306] 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 36d8ec45a..6d3a6f131 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 9acdb90da..69c8dce48 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 261/306] 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 e9c0b687f..0bcb60a09 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 262/306] 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 4a6655714..afa882320 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 0bcb60a09..947bf3a8e 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 263/306] 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 afa882320..56601c455 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 947bf3a8e..bb530cbb9 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 264/306] 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 b0601f311..cd72109c2 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 6d3a6f131..2d0634401 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 265/306] 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 2d0634401..310a31973 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 266/306] :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 310a31973..4d4d40659 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 267/306] 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 4d4d40659..9fc96dd71 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 268/306] 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 9fc96dd71..405fb2ee1 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 269/306] 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 cd72109c2..2f319863e 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 405fb2ee1..aff7da73b 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 270/306] 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 aff7da73b..79798c7d6 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 271/306] 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 79798c7d6..f8ef0af26 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 272/306] 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 f8ef0af26..2bc0b581b 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 273/306] 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 2bc0b581b..088fdf5d3 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 274/306] 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 088fdf5d3..d8784080a 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,48 +2303,72 @@ 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()) + ) + } + + return this.derivedDimensionsCache.lastVisibleRow + } + + getVisibleTileCount () { + 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()) } - getVisibleTileCount () { - return Math.floor((this.getLastVisibleRow() - this.getFirstVisibleRow()) / this.getRowsPerTile()) + 2 - } - getScrollTop () { this.scrollTop = Math.min(this.getMaxScrollTop(), this.scrollTop) return this.scrollTop @@ -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 275/306] 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 56601c455..60b0fd708 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 bb530cbb9..04e22447f 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 276/306] 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 2f319863e..71f5c1539 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 d8784080a..66f372ea1 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 277/306] 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 30b640733..ec037e9e4 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 c162db420..ac90e4a71 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 278/306] 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 | 445 +++++++++++++---------------- 2 files changed, 208 insertions(+), 257 deletions(-) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 71f5c1539..4cff55db3 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 66f372ea1..2ee3a882a 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,35 +3107,195 @@ class LinesTileComponent { return $.div( { className: 'highlights', - style: { - position: 'absolute', - contain: 'strict', - height: height + 'px', - width: width + 'px' - } - }, children + style: {contain: 'layout'} + }, + children ) } - renderLines () { + createLines () { const { - measuredContent, height, width, - tileStartRow, screenLines, lineDecorations, blockDecorations, displayLayer, - lineNodesByScreenLineId, textNodesByScreenLineId + element, tileStartRow, screenLines, lineDecorations, + displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId } = this.props - return $(LinesComponent, { - measuredContent, - height, - width, - tileStartRow, - screenLines, - lineDecorations, - blockDecorations, - displayLayer, - lineNodesByScreenLineId, - textNodesByScreenLineId - }) + this.lineComponents = [] + 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, + textNodesByScreenLineId + }) + this.element.appendChild(component.element) + this.lineComponents.push(component) + } + } + + updateLines (oldProps, newProps) { + var { + screenLines, tileStartRow, lineDecorations, + displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId + } = newProps + + var oldScreenLines = oldProps.screenLines + var newScreenLines = screenLines + var oldScreenLinesEndIndex = oldScreenLines.length + var newScreenLinesEndIndex = newScreenLines.length + var oldScreenLineIndex = 0 + var newScreenLineIndex = 0 + 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, + screenRow: tileStartRow + newScreenLineIndex, + 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({ + screenRow: tileStartRow + newScreenLineIndex, + 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 newScreenLineComponent = new LineComponent({ // eslint-disable-line no-redeclare + screenLine: newScreenLines[newScreenLineIndex], + screenRow: tileStartRow + newScreenLineIndex, + lineDecoration: lineDecorations[newScreenLineIndex], + displayLayer, + lineNodesByScreenLineId, + textNodesByScreenLineId + }) + this.element.insertBefore(newScreenLineComponent.element, this.getFirstElementForScreenLine(oldProps, oldScreenLine)) + 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({ // eslint-disable-line no-redeclare + screenLine: newScreenLines[newScreenLineIndex], + screenRow: tileStartRow + newScreenLineIndex, + lineDecoration: lineDecorations[newScreenLineIndex], + displayLayer, + lineNodesByScreenLineId, + 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() + this.lineComponents[lineComponentIndex] = newScreenLineComponent + + oldScreenLineIndex++ + newScreenLineIndex++ + lineComponentIndex++ + } + } + } + } + + 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++) { + var decoration = blockDecorations[i] + if (decoration.position !== 'after') { + blockDecorationElementsBeforeOldScreenLine.push( + TextEditor.viewForItem(decoration.item) + ) + } + } + + for (let i = 0; i < blockDecorationElementsBeforeOldScreenLine.length; i++) { + var blockDecorationElement = blockDecorationElementsBeforeOldScreenLine[i] + if (!blockDecorationElementsBeforeOldScreenLine.includes(blockDecorationElement.previousSibling)) { + return blockDecorationElement + } + } + } + + return oldProps.lineNodesByScreenLineId.get(screenLine.id) + } + + updateBlockDecorations (oldProps, newProps) { + var {blockDecorations, lineNodesByScreenLineId} = newProps + + 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 + + var element = TextEditor.viewForItem(oldDecoration.item) + if (element.parentElement !== this.element) continue + + element.remove() + } + }) + } + + if (blockDecorations) { + blockDecorations.forEach((newDecorations, screenLineId) => { + 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 + + 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) + } + } + }) + } } shouldUpdate (newProps) { @@ -3185,228 +3358,6 @@ class LinesTileComponent { } } -class LinesComponent { - constructor (props) { - this.props = {} - const { - width, height, 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.lineComponents = [] - 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, - textNodesByScreenLineId - }) - 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) { - var { - screenLines, tileStartRow, lineDecorations, - displayLayer, lineNodesByScreenLineId, textNodesByScreenLineId - } = props - - var oldScreenLines = this.props.screenLines - var newScreenLines = screenLines - var oldScreenLinesEndIndex = oldScreenLines.length - var newScreenLinesEndIndex = newScreenLines.length - var oldScreenLineIndex = 0 - var newScreenLineIndex = 0 - 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, - screenRow: tileStartRow + newScreenLineIndex, - 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({ - screenRow: tileStartRow + newScreenLineIndex, - 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 newScreenLineComponent = new LineComponent({ // eslint-disable-line no-redeclare - screenLine: newScreenLines[newScreenLineIndex], - screenRow: tileStartRow + newScreenLineIndex, - lineDecoration: lineDecorations[newScreenLineIndex], - displayLayer, - lineNodesByScreenLineId, - textNodesByScreenLineId - }) - this.element.insertBefore(newScreenLineComponent.element, this.getFirstElementForScreenLine(oldScreenLine)) - 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({ // eslint-disable-line no-redeclare - screenLine: newScreenLines[newScreenLineIndex], - screenRow: tileStartRow + newScreenLineIndex, - lineDecoration: lineDecorations[newScreenLineIndex], - displayLayer, - lineNodesByScreenLineId, - 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() - this.lineComponents[lineComponentIndex] = newScreenLineComponent - - oldScreenLineIndex++ - newScreenLineIndex++ - lineComponentIndex++ - } - } - } - } - - getFirstElementForScreenLine (screenLine) { - var blockDecorations = this.props.blockDecorations ? this.props.blockDecorations.get(screenLine.id) : null - if (blockDecorations) { - var blockDecorationElementsBeforeOldScreenLine = [] - for (let i = 0; i < blockDecorations.length; i++) { - var decoration = blockDecorations[i] - if (decoration.position !== 'after') { - blockDecorationElementsBeforeOldScreenLine.push( - TextEditor.viewForItem(decoration.item) - ) - } - } - - for (let 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 (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] - 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) - } - } - }) - } - } -} - class LineComponent { constructor (props) { const { From c7228f6d81f473e5a8ee8033d0df39eb8aeb5230 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 24 Apr 2017 16:30:15 -0600 Subject: [PATCH 279/306] 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 4cff55db3..df8dbae9a 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 2ee3a882a..bb28b917d 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 280/306] 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 df8dbae9a..fd2af0ce2 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 bb28b917d..0c9ea9837 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 281/306] 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 0c9ea9837..2e8f8839b 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 282/306] 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 fd2af0ce2..258be57aa 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 2e8f8839b..56d008fa2 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 283/306] 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 cc703bbec..e1440c675 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 eff79ec95..5b1863b35 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 000000000..826c47fa0 --- /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 56d008fa2..4892a5cdc 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 4864663fe..8b171eb67 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 540b4ad3a..908dd53ae 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 234f82be9..a828950b3 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 284/306] 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 258be57aa..b2e825986 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 4892a5cdc..57691e039 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() + 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 } - 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) - } - - 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 285/306] 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 b2e825986..1de2a091a 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 57691e039..6049fb2a1 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 286/306] 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 826c47fa0..0ca312834 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 8b171eb67..4864663fe 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 287/306] :arrow_up: text-buffer --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6eb57d2fe..f907162d5 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 288/306] 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 ac5183cab..79d575a5f 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 3c2afc6ab..99e8f497f 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 4864663fe..248d87d81 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 289/306] 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 1de2a091a..8421708d6 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 6049fb2a1..3215db760 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 04e22447f..0c8b50a62 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 290/306] 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 8421708d6..f4abd9544 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 3215db760..ce37d329c 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 291/306] 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 69c8dce48..91010fb3e 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 292/306] 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 60b0fd708..cb778e3ed 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 ce37d329c..bc32dd206 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 0c8b50a62..275b3b702 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 293/306] 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 f4abd9544..3333c1d1d 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 bc32dd206..51da03222 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 275b3b702..d56c5596b 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 294/306] 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 3333c1d1d..614802eda 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 51da03222..ae4db7f9d 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 295/306] 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 ae4db7f9d..f2614188f 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 296/306] 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 614802eda..3a55cf992 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 f2614188f..84ec0f459 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 297/306] 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 99e8f497f..cf6a7e303 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 e1440c675..14e656a8f 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 248d87d81..e5a64c371 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 298/306] 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 84ec0f459..edd534559 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 299/306] 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 3a55cf992..d68ba5c00 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 edd534559..c339cdf96 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 300/306] 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 d68ba5c00..b5d6d98c7 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 c339cdf96..fcc35bc55 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 301/306] 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 91010fb3e..06518ac0f 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 302/306] 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 b5d6d98c7..e13086961 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 e5a64c371..a7d321dd0 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 303/306] 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 a7d321dd0..21921b4c4 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 304/306] 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 e13086961..006f2859e 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 fcc35bc55..87f66875a 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 305/306] 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 21921b4c4..df3f4a9b5 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 306/306] 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 87f66875a..6d3ca9cc5 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