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 ###