diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index b9c469829..cf3e0a06e 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -165,7 +165,7 @@ describe "EditorComponent", -> beforeEach -> editor.setText "a line that wraps " editor.setSoftWrap(true) - node.style.width = 15 * charWidth + 'px' + node.style.width = 16 * charWidth + 'px' component.measureScrollView() it "doesn't show end of line invisibles at the end of wrapped lines", -> @@ -228,6 +228,13 @@ describe "EditorComponent", -> [node] describe "gutter rendering", -> + [lineNumberHasClass, gutter] = [] + + beforeEach -> + {gutter} = component.refs + lineNumberHasClass = (screenRow, klass) -> + component.lineNumberNodeForScreenRow(screenRow).classList.contains(klass) + it "renders the currently-visible line numbers", -> node.style.height = 4.5 * lineHeightInPixels + 'px' component.measureScrollView() @@ -302,6 +309,267 @@ describe "EditorComponent", -> expect(component.lineNumberNodeForScreenRow(9).textContent).toBe "10" expect(gutterNode.offsetWidth).toBe initialGutterWidth + describe "fold decorations", -> + describe "rendering fold decorations", -> + it "adds the foldable class to line numbers when the line is foldable", -> + 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", -> + editor.getBuffer().insert([0, 0], '\n') + 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", -> + expect(lineNumberHasClass(11, 'foldable')).toBe false + + editor.getBuffer().insert([11, 44], '\n fold me') + expect(lineNumberHasClass(11, 'foldable')).toBe true + + editor.undo() + expect(lineNumberHasClass(11, 'foldable')).toBe false + + it "adds, updates and removes the folded class on the correct line number nodes", -> + editor.foldBufferRow(4) + expect(lineNumberHasClass(4, 'folded')).toBe true + + editor.getBuffer().insert([0, 0], '\n') + expect(lineNumberHasClass(4, 'folded')).toBe false + expect(lineNumberHasClass(5, 'folded')).toBe true + + editor.unfoldBufferRow(5) + expect(lineNumberHasClass(5, 'folded')).toBe false + + describe "mouse interactions with fold indicators", -> + [gutterNode] = [] + + buildClickEvent = (target) -> + buildMouseEvent('click', {target}) + + beforeEach -> + gutterNode = node.querySelector('.gutter') + + it "folds and unfolds the block represented by the fold indicator when clicked", -> + expect(lineNumberHasClass(1, 'folded')).toBe false + + lineNumber = component.lineNumberNodeForScreenRow(1) + target = lineNumber.querySelector('.icon-right') + + target.dispatchEvent(buildClickEvent(target)) + expect(lineNumberHasClass(1, 'folded')).toBe true + + lineNumber = component.lineNumberNodeForScreenRow(1) + target = lineNumber.querySelector('.icon-right') + + target.dispatchEvent(buildClickEvent(target)) + expect(lineNumberHasClass(1, 'folded')).toBe false + + it "does not fold when the line number node is clicked", -> + lineNumber = component.lineNumberNodeForScreenRow(1) + lineNumber.dispatchEvent(buildClickEvent(lineNumber)) + expect(lineNumberHasClass(1, 'folded')).toBe false + + describe "cursor-line decorations", -> + cursor = null + beforeEach -> + cursor = editor.getCursor() + + it "modifies the cursor-line decoration when the cursor moves", -> + cursor.setScreenPosition([0, 0]) + expect(lineNumberHasClass(0, 'cursor-line')).toBe true + + cursor.setScreenPosition([1, 0]) + expect(lineNumberHasClass(0, 'cursor-line')).toBe false + expect(lineNumberHasClass(1, 'cursor-line')).toBe true + + it "updates cursor-line decorations for multiple cursors", -> + cursor.setScreenPosition([2, 0]) + cursor2 = editor.addCursorAtScreenPosition([8, 0]) + cursor3 = editor.addCursorAtScreenPosition([10, 0]) + + expect(lineNumberHasClass(2, 'cursor-line')).toBe true + expect(lineNumberHasClass(8, 'cursor-line')).toBe true + expect(lineNumberHasClass(10, 'cursor-line')).toBe true + + cursor2.destroy() + expect(lineNumberHasClass(2, 'cursor-line')).toBe true + expect(lineNumberHasClass(8, 'cursor-line')).toBe false + expect(lineNumberHasClass(10, 'cursor-line')).toBe true + + cursor3.destroy() + expect(lineNumberHasClass(2, 'cursor-line')).toBe true + expect(lineNumberHasClass(8, 'cursor-line')).toBe false + expect(lineNumberHasClass(10, 'cursor-line')).toBe false + + it "adds cursor-line decorations to multiple lines when a selection is performed", -> + cursor.setScreenPosition([1, 0]) + editor.selectDown(2) + expect(lineNumberHasClass(0, 'cursor-line')).toBe false + expect(lineNumberHasClass(1, 'cursor-line')).toBe true + expect(lineNumberHasClass(2, 'cursor-line')).toBe true + expect(lineNumberHasClass(3, 'cursor-line')).toBe true + expect(lineNumberHasClass(4, 'cursor-line')).toBe false + + describe "when decorations are used", -> + describe "when decorations are applied to buffer rows", -> + it "renders line number classes based on the decorations on their buffer row", -> + node.style.height = 4.5 * lineHeightInPixels + 'px' + component.measureScrollView() + + expect(component.lineNumberNodeForScreenRow(9)).not.toBeDefined() + + editor.addDecorationToBufferRow(9, type: 'gutter', class: 'fancy-class') + editor.addDecorationToBufferRow(9, type: 'someother-type', class: 'nope-class') + + verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels + verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) + + expect(lineNumberHasClass(9, 'fancy-class')).toBe true + expect(lineNumberHasClass(9, 'nope-class')).toBe false + + it "renders updates to gutter decorations", -> + editor.addDecorationToBufferRow(2, type: 'gutter', class: 'fancy-class') + editor.addDecorationToBufferRow(2, type: 'someother-type', class: 'nope-class') + + waitsFor -> not component.decorationChangedImmediate? + runs -> + expect(lineNumberHasClass(2, 'fancy-class')).toBe true + expect(lineNumberHasClass(2, 'nope-class')).toBe false + + editor.removeDecorationFromBufferRow(2, type: 'gutter', class: 'fancy-class') + editor.removeDecorationFromBufferRow(2, type: 'someother-type', class: 'nope-class') + + waitsFor -> not component.decorationChangedImmediate? + runs -> + expect(lineNumberHasClass(2, 'fancy-class')).toBe false + expect(lineNumberHasClass(2, 'nope-class')).toBe false + + it "renders decorations on soft-wrapped line numbers when softWrap is true", -> + editor.addDecorationToBufferRow(1, type: 'gutter', class: 'no-wrap') + editor.addDecorationToBufferRow(1, type: 'gutter', class: 'wrap-me', softWrap: true) + + editor.setSoftWrap(true) + node.style.height = 4.5 * lineHeightInPixels + 'px' + node.style.width = 30 * charWidth + 'px' + component.measureScrollView() + + expect(lineNumberHasClass(2, 'no-wrap')).toBe true + expect(lineNumberHasClass(2, 'wrap-me')).toBe true + expect(lineNumberHasClass(3, 'no-wrap')).toBe false + expect(lineNumberHasClass(3, 'wrap-me')).toBe true + + # should remove the wrapped decorations + editor.removeDecorationFromBufferRow(1, type: 'gutter', class: 'no-wrap') + editor.removeDecorationFromBufferRow(1, type: 'gutter', class: 'wrap-me') + + waitsFor -> not component.decorationChangedImmediate? + runs -> + expect(lineNumberHasClass(2, 'no-wrap')).toBe false + expect(lineNumberHasClass(2, 'wrap-me')).toBe false + expect(lineNumberHasClass(3, 'no-wrap')).toBe false + expect(lineNumberHasClass(3, 'wrap-me')).toBe false + + # should add them back when the nodes are not recreated + editor.addDecorationToBufferRow(1, type: 'gutter', class: 'no-wrap') + editor.addDecorationToBufferRow(1, type: 'gutter', class: 'wrap-me', softWrap: true) + + waitsFor -> not component.decorationChangedImmediate? + runs -> + expect(lineNumberHasClass(2, 'no-wrap')).toBe true + expect(lineNumberHasClass(2, 'wrap-me')).toBe true + expect(lineNumberHasClass(3, 'no-wrap')).toBe false + expect(lineNumberHasClass(3, 'wrap-me')).toBe true + + describe "when decorations are applied to markers", -> + {marker, decoration} = {} + beforeEach -> + marker = editor.displayBuffer.markBufferRange([[2, 13], [3, 15]], class: 'my-marker', invalidate: 'inside') + decoration = {type: 'gutter', class: 'someclass'} + editor.addDecorationForMarker(marker, decoration) + waitsFor -> not component.decorationChangedImmediate? + + it "updates line number classes when the marker moves", -> + expect(lineNumberHasClass(1, 'someclass')).toBe false + expect(lineNumberHasClass(2, 'someclass')).toBe true + expect(lineNumberHasClass(3, 'someclass')).toBe true + expect(lineNumberHasClass(4, 'someclass')).toBe false + + editor.getBuffer().insert([0, 0], '\n') + + waitsFor -> not component.decorationChangedImmediate? + runs -> + expect(lineNumberHasClass(2, 'someclass')).toBe false + expect(lineNumberHasClass(3, 'someclass')).toBe true + expect(lineNumberHasClass(4, 'someclass')).toBe true + expect(lineNumberHasClass(5, 'someclass')).toBe false + + editor.getBuffer().deleteRows(0, 1) + + waitsFor -> not component.decorationChangedImmediate? + runs -> + expect(lineNumberHasClass(0, 'someclass')).toBe false + expect(lineNumberHasClass(1, 'someclass')).toBe true + expect(lineNumberHasClass(2, 'someclass')).toBe true + expect(lineNumberHasClass(3, 'someclass')).toBe false + + it "removes line number classes when a decoration's marker is invalidated", -> + editor.getBuffer().insert([3, 2], 'n') + + waitsFor -> not component.decorationChangedImmediate? + runs -> + + expect(marker.isValid()).toBe false + expect(lineNumberHasClass(1, 'someclass')).toBe false + expect(lineNumberHasClass(2, 'someclass')).toBe false + expect(lineNumberHasClass(3, 'someclass')).toBe false + expect(lineNumberHasClass(4, 'someclass')).toBe false + + editor.getBuffer().undo() + + waitsFor -> not component.decorationChangedImmediate? + runs -> + expect(marker.isValid()).toBe true + expect(lineNumberHasClass(1, 'someclass')).toBe false + expect(lineNumberHasClass(2, 'someclass')).toBe true + expect(lineNumberHasClass(3, 'someclass')).toBe true + expect(lineNumberHasClass(4, 'someclass')).toBe false + + it "removes the classes and unsubscribes from the marker when decoration is removed", -> + editor.removeDecorationForMarker(marker, decoration) + + waitsFor -> not component.decorationChangedImmediate? + runs -> + expect(lineNumberHasClass(1, 'someclass')).toBe false + expect(lineNumberHasClass(2, 'someclass')).toBe false + expect(lineNumberHasClass(3, 'someclass')).toBe false + expect(lineNumberHasClass(4, 'someclass')).toBe false + + editor.getBuffer().insert([0, 0], '\n') + + waitsFor -> not component.decorationChangedImmediate? + runs -> + expect(lineNumberHasClass(2, 'someclass')).toBe false + expect(lineNumberHasClass(3, 'someclass')).toBe false + + it "removes the line number classes when the decoration's marker is destroyed", -> + marker.destroy() + + waitsFor -> not component.decorationChangedImmediate? + runs -> + expect(lineNumberHasClass(1, 'someclass')).toBe false + expect(lineNumberHasClass(2, 'someclass')).toBe false + expect(lineNumberHasClass(3, 'someclass')).toBe false + expect(lineNumberHasClass(4, 'someclass')).toBe false + describe "cursor rendering", -> it "renders the currently visible cursors, translated relative to the scroll position", -> cursor1 = editor.getCursor() @@ -630,12 +898,6 @@ describe "EditorComponent", -> clientY = scrollViewClientRect.top + positionOffset.top - editor.getScrollTop() {clientX, clientY} - buildMouseEvent = (type, properties...) -> - properties = extend({bubbles: true, cancelable: true}, properties...) - event = new MouseEvent(type, properties) - Object.defineProperty(event, 'which', get: -> properties.which) if properties.which? - event - describe "focus handling", -> inputNode = null @@ -654,6 +916,22 @@ describe "EditorComponent", -> inputNode.blur() expect(node.classList.contains('is-focused')).toBe false + describe "selection handling", -> + cursor = null + + beforeEach -> + cursor = editor.getCursor() + cursor.setScreenPosition([0, 0]) + + it "adds the 'has-selection' class to the editor when there is a selection", -> + expect(node.classList.contains('has-selection')).toBe false + + editor.selectDown() + expect(node.classList.contains('has-selection')).toBe true + + cursor.moveDown() + expect(node.classList.contains('has-selection')).toBe false + describe "scrolling", -> it "updates the vertical scrollbar when the scrollTop is changed in the model", -> node.style.height = 4.5 * lineHeightInPixels + 'px' @@ -940,3 +1218,12 @@ describe "EditorComponent", -> editor.setCursorBufferPosition([0, Infinity]) wrapperView.show() expect(node.querySelector('.cursor').style['-webkit-transform']).toBe "translate3d(#{9 * charWidth}px, 0px, 0px)" + + buildMouseEvent = (type, properties...) -> + properties = extend({bubbles: true, cancelable: true}, properties...) + event = new MouseEvent(type, properties) + Object.defineProperty(event, 'which', get: -> properties.which) if properties.which? + if properties.target? + Object.defineProperty(event, 'target', get: -> properties.target) + Object.defineProperty(event, 'srcObject', get: -> properties.target) + event diff --git a/spec/editor-spec.coffee b/spec/editor-spec.coffee index 704d062fb..ec0bdd76e 100644 --- a/spec/editor-spec.coffee +++ b/spec/editor-spec.coffee @@ -1622,7 +1622,7 @@ describe "Editor", -> editor.setCursorBufferPosition([9,2]) editor.insertNewline() expect(editor.lineForBufferRow(10)).toBe ' };' - + describe ".backspace()", -> describe "when there is a single cursor", -> changeScreenRangeHandler = null @@ -3205,3 +3205,114 @@ describe "Editor", -> editor.pageUp() expect(editor.getScrollTop()).toBe 0 + + describe "decorations", -> + decoration = null + beforeEach -> + decoration = {type: 'gutter', class: 'one'} + + it "can add decorations to buffer rows and remove them", -> + editor.addDecorationToBufferRow(2, decoration) + editor.addDecorationToBufferRow(2, decoration) + + decorations = editor.decorationsForBufferRow(2) + expect(decorations).toHaveLength 1 + expect(decorations).toContain decoration + + editor.removeDecorationFromBufferRow(2, decoration) + decorations = editor.decorationsForBufferRow(2) + expect(decorations).toHaveLength 0 + + it "can add decorations to buffer row ranges and remove them", -> + editor.addDecorationToBufferRowRange(2, 4, decoration) + expect(editor.decorationsForBufferRow 2).toContain decoration + expect(editor.decorationsForBufferRow 3).toContain decoration + expect(editor.decorationsForBufferRow 4).toContain decoration + + editor.removeDecorationFromBufferRowRange(3, 5, decoration) + expect(editor.decorationsForBufferRow 2).toContain decoration + expect(editor.decorationsForBufferRow 3).not.toContain decoration + expect(editor.decorationsForBufferRow 4).not.toContain decoration + + it "can add decorations associated with markers and remove them", -> + marker = editor.displayBuffer.markBufferRange([[2, 13], [3, 15]], class: 'my-marker', invalidate: 'inside') + + editor.addDecorationForMarker(marker, decoration) + expect(editor.decorationsForBufferRow 1).not.toContain decoration + expect(editor.decorationsForBufferRow 2).toContain decoration + expect(editor.decorationsForBufferRow 3).toContain decoration + expect(editor.decorationsForBufferRow 4).not.toContain decoration + + editor.getBuffer().insert([0, 0], '\n') + expect(editor.decorationsForBufferRow 2).not.toContain decoration + expect(editor.decorationsForBufferRow 3).toContain decoration + expect(editor.decorationsForBufferRow 4).toContain decoration + expect(editor.decorationsForBufferRow 5).not.toContain decoration + + editor.getBuffer().insert([4, 2], 'n') + expect(editor.decorationsForBufferRow 2).not.toContain decoration + expect(editor.decorationsForBufferRow 3).not.toContain decoration + expect(editor.decorationsForBufferRow 4).not.toContain decoration + expect(editor.decorationsForBufferRow 5).not.toContain decoration + + editor.getBuffer().undo() + expect(editor.decorationsForBufferRow 2).not.toContain decoration + expect(editor.decorationsForBufferRow 3).toContain decoration + expect(editor.decorationsForBufferRow 4).toContain decoration + expect(editor.decorationsForBufferRow 5).not.toContain decoration + + editor.removeDecorationForMarker(marker, decoration) + expect(editor.decorationsForBufferRow 2).not.toContain decoration + expect(editor.decorationsForBufferRow 3).not.toContain decoration + expect(editor.decorationsForBufferRow 4).not.toContain decoration + expect(editor.decorationsForBufferRow 5).not.toContain decoration + + describe "decorationsForBufferRow", -> + one = {type: 'one', class: 'one'} + two = {type: 'two', class: 'two'} + typeless = {class: 'typeless'} + + beforeEach -> + editor.addDecorationToBufferRow(2, one) + editor.addDecorationToBufferRow(2, two) + editor.addDecorationToBufferRow(2, typeless) + + it "returns all decorations with no decorationType specified", -> + decorations = editor.decorationsForBufferRow(2) + expect(decorations).toContain one + expect(decorations).toContain two + expect(decorations).toContain typeless + + it "returns typeless decorations with all decorationTypes", -> + decorations = editor.decorationsForBufferRow(2, 'one') + expect(decorations).toContain one + expect(decorations).not.toContain two + expect(decorations).toContain typeless + + describe "decorationsForBufferRowRange", -> + one = {type: 'one', class: 'one'} + two = {type: 'two', class: 'two'} + typeless = {class: 'typeless'} + + it "returns an object of decorations based on the decorationType", -> + editor.addDecorationToBufferRow(2, one) + editor.addDecorationToBufferRow(3, one) + editor.addDecorationToBufferRow(5, one) + + editor.addDecorationToBufferRow(3, two) + editor.addDecorationToBufferRow(4, two) + + editor.addDecorationToBufferRow(3, typeless) + editor.addDecorationToBufferRow(5, typeless) + + decorations = editor.decorationsForBufferRowRange(2, 5, 'one') + expect(decorations[2]).toContain one + + expect(decorations[3]).toContain one + expect(decorations[3]).not.toContain two + expect(decorations[3]).toContain typeless + + expect(decorations[4]).toHaveLength 0 + + expect(decorations[5]).toContain one + expect(decorations[5]).toContain typeless diff --git a/src/display-buffer-marker.coffee b/src/display-buffer-marker.coffee index c3a902b99..e2324f6b7 100644 --- a/src/display-buffer-marker.coffee +++ b/src/display-buffer-marker.coffee @@ -111,6 +111,34 @@ class DisplayBufferMarker setTailBufferPosition: (bufferPosition) -> @bufferMarker.setTailPosition(bufferPosition) + # Retrieves the screen position of the marker's start. This will always be + # less than or equal to the result of {DisplayBufferMarker::getEndScreenPosition}. + # + # Returns a {Point}. + getStartScreenPosition: -> + @displayBuffer.screenPositionForBufferPosition(@getStartBufferPosition(), wrapAtSoftNewlines: true) + + # Retrieves the buffer position of the marker's start. This will always be + # less than or equal to the result of {DisplayBufferMarker::getEndBufferPosition}. + # + # Returns a {Point}. + getStartBufferPosition: -> + @bufferMarker.getStartPosition() + + # Retrieves the screen position of the marker's end. This will always be + # greater than or equal to the result of {DisplayBufferMarker::getStartScreenPosition}. + # + # Returns a {Point}. + getEndScreenPosition: -> + @displayBuffer.screenPositionForBufferPosition(@getEndBufferPosition(), wrapAtSoftNewlines: true) + + # Retrieves the buffer position of the marker's end. This will always be + # greater than or equal to the result of {DisplayBufferMarker::getStartBufferPosition}. + # + # Returns a {Point}. + getEndBufferPosition: -> + @bufferMarker.getEndPosition() + # Sets the marker's tail to the same position as the marker's head. # # This only works if there isn't already a tail position. diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index 579863ec2..c8b3ed97c 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -43,6 +43,8 @@ class DisplayBuffer extends Model @charWidthsByScope = {} @markers = {} @foldsByMarkerId = {} + @decorations = {} + @decorationMarkerSubscriptions = {} @updateAllScreenLines() @createFoldForMarker(marker) for marker in @buffer.findMarkers(@getFoldMarkerAttributes()) @subscribe @tokenizedBuffer, 'grammar-changed', (grammar) => @emit 'grammar-changed', grammar @@ -718,6 +720,97 @@ class DisplayBuffer extends Model rangeForAllLines: -> new Range([0, 0], @clipScreenPosition([Infinity, Infinity])) + decorationsForBufferRow: (bufferRow, decorationType) -> + decorations = @decorations[bufferRow] ? [] + decorations = (dec for dec in decorations when not dec.type? or dec.type is decorationType) if decorationType? + decorations + + decorationsForBufferRowRange: (startBufferRow, endBufferRow, decorationType) -> + decorations = {} + for bufferRow in [startBufferRow..endBufferRow] + decorations[bufferRow] = @decorationsForBufferRow(bufferRow, decorationType) + decorations + + addDecorationToBufferRow: (bufferRow, decoration) -> + @decorations[bufferRow] ?= [] + for current in @decorations[bufferRow] + return if _.isEqual(current, decoration) + @decorations[bufferRow].push(decoration) + @emit 'decoration-changed', {bufferRow, decoration, action: 'add'} + + removeDecorationFromBufferRow: (bufferRow, decorationPattern) -> + return unless decorations = @decorations[bufferRow] + + removed = [] + i = decorations.length - 1 + while i >= 0 + if @decorationMatchesPattern(decorations[i], decorationPattern) + removed.push decorations[i] + decorations.splice(i, 1) + i-- + + delete @decorations[bufferRow] unless @decorations[bufferRow]? + + for decoration in removed + @emit 'decoration-changed', {bufferRow, decoration, action: 'remove'} + + removed + + addDecorationToBufferRowRange: (startBufferRow, endBufferRow, decoration) -> + for bufferRow in [startBufferRow..endBufferRow] + @addDecorationToBufferRow(bufferRow, decoration) + return + + removeDecorationFromBufferRowRange: (startBufferRow, endBufferRow, decoration) -> + for bufferRow in [startBufferRow..endBufferRow] + @removeDecorationFromBufferRow(bufferRow, decoration) + return + + decorationMatchesPattern: (decoration, decorationPattern) -> + return false unless decoration? and decorationPattern? + for key, value of decorationPattern + return false if decoration[key] != value + true + + addDecorationForMarker: (marker, decoration) -> + startRow = marker.getStartBufferPosition().row + endRow = marker.getEndBufferPosition().row + @addDecorationToBufferRowRange(startRow, endRow, decoration) + + changedSubscription = @subscribe marker, 'changed', (e) => + oldStartRow = e.oldHeadBufferPosition.row + oldEndRow = e.oldTailBufferPosition.row + newStartRow = e.newHeadBufferPosition.row + newEndRow = e.newTailBufferPosition.row + + # swap so head is always <= than tail + [oldEndRow, oldStartRow] = [oldStartRow, oldEndRow] if oldStartRow > oldEndRow + [newEndRow, newStartRow] = [newStartRow, newEndRow] if newStartRow > newEndRow + + @removeDecorationFromBufferRowRange(oldStartRow, oldEndRow, decoration) + @addDecorationToBufferRowRange(newStartRow, newEndRow, decoration) if e.isValid + + destroyedSubscription = @subscribe marker, 'destroyed', (e) => + @removeDecorationForMarker(marker, decoration) + + @decorationMarkerSubscriptions[marker.id] ?= [] + @decorationMarkerSubscriptions[marker.id].push {decoration, changedSubscription, destroyedSubscription} + + removeDecorationForMarker: (marker, decorationPattern) -> + return unless @decorationMarkerSubscriptions[marker.id]? + + startRow = marker.getStartBufferPosition().row + endRow = marker.getEndBufferPosition().row + @removeDecorationFromBufferRowRange(startRow, endRow, decorationPattern) + + for subscription in _.clone(@decorationMarkerSubscriptions[marker.id]) + if @decorationMatchesPattern(subscription.decoration, decorationPattern) + subscription.changedSubscription.off() + subscription.destroyedSubscription.off() + @decorationMarkerSubscriptions[marker.id] = _.without(@decorationMarkerSubscriptions[marker.id], subscription) + + return + # Retrieves a {DisplayBufferMarker} based on its id. # # id - A {Number} representing a marker id @@ -961,6 +1054,8 @@ class DisplayBuffer extends Model @emit 'marker-created', @getMarker(marker.id) createFoldForMarker: (marker) -> + bufferMarker = new DisplayBufferMarker({bufferMarker: marker, displayBuffer: this}) + @addDecorationForMarker(bufferMarker, type: 'gutter', class: 'folded') new Fold(this, marker) foldForMarker: (marker) -> diff --git a/src/editor-component.coffee b/src/editor-component.coffee index 1d1006290..a0f731264 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -43,12 +43,14 @@ EditorComponent = React.createClass {editor, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props maxLineNumberDigits = editor.getScreenLineCount().toString().length invisibles = if showInvisibles then @state.invisibles else {} + hasSelection = editor.getSelection()? and !editor.getSelection().isEmpty() if @isMounted() renderedRowRange = @getRenderedRowRange() [renderedStartRow, renderedEndRow] = renderedRowRange cursorScreenRanges = @getCursorScreenRanges(renderedRowRange) selectionScreenRanges = @getSelectionScreenRanges(renderedRowRange) + decorations = @getGutterDecorations(renderedRowRange) scrollHeight = editor.getScrollHeight() scrollWidth = editor.getScrollWidth() scrollTop = editor.getScrollTop() @@ -67,11 +69,13 @@ EditorComponent = React.createClass className = 'editor-contents editor-colors' className += ' is-focused' if focused + className += ' has-selection' if hasSelection div className: className, style: {fontSize, lineHeight, fontFamily}, tabIndex: -1, GutterComponent { ref: 'gutter', editor, renderedRowRange, maxLineNumberDigits, scrollTop, - scrollHeight, lineHeightInPixels, @pendingChanges, mouseWheelScreenRow + scrollHeight, lineHeightInPixels, @pendingChanges, mouseWheelScreenRow, + decorations } div ref: 'scrollView', className: 'scroll-view', onMouseDown: @onMouseDown, @@ -225,6 +229,18 @@ EditorComponent = React.createClass selectionScreenRanges + getGutterDecorations: (renderedRowRange) -> + {editor} = @props + [renderedStartRow, renderedEndRow] = renderedRowRange + + bufferRows = editor.bufferRowsForScreenRows(renderedStartRow, renderedEndRow - 1) + + decorations = {} + for bufferRow in bufferRows + decorations[bufferRow] = editor.decorationsForBufferRow(bufferRow, 'gutter') + decorations[bufferRow].push {class: 'foldable'} if editor.isFoldableAtBufferRow(bufferRow) + decorations + observeEditor: -> {editor} = @props @subscribe editor, 'batched-updates-started', @onBatchedUpdatesStarted @@ -233,6 +249,7 @@ EditorComponent = React.createClass @subscribe editor, 'cursors-moved', @onCursorsMoved @subscribe editor, 'selection-removed selection-screen-range-changed', @onSelectionChanged @subscribe editor, 'selection-added', @onSelectionAdded + @subscribe editor, 'decoration-changed', @onDecorationChanged @subscribe editor.$scrollTop.changes, @onScrollTopChanged @subscribe editor.$scrollLeft.changes, @requestUpdate @subscribe editor.$height.changes, @requestUpdate @@ -510,6 +527,11 @@ EditorComponent = React.createClass onCursorsMoved: -> @cursorsMoved = true + onDecorationChanged: -> + @decorationChangedImmediate ?= setImmediate => + @requestUpdate() + @decorationChangedImmediate = null + selectToMousePositionUntilMouseUp: (event) -> {editor} = @props dragging = false diff --git a/src/editor.coffee b/src/editor.coffee index b676130e4..e2e314a4e 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -214,6 +214,7 @@ class Editor extends Model @subscribe @displayBuffer, 'grammar-changed', => @handleGrammarChange() @subscribe @displayBuffer, 'tokenized', => @handleTokenization() @subscribe @displayBuffer, 'soft-wrap-changed', (args...) => @emit 'soft-wrap-changed', args... + @subscribe @displayBuffer, "decoration-changed", (e) => @emit 'decoration-changed', e getViewClass: -> if atom.config.get('core.useReactEditor') @@ -1057,6 +1058,106 @@ class Editor extends Model selection.insertText(fn(text)) selection.setBufferRange(range) + # Public: Get all the decorations for a buffer row. + # + # bufferRow - the {int} buffer row + # decorationType - the {String} decoration type to filter by eg. 'gutter' + # + # Returns an {Array} of decorations in the form `[{type: 'gutter', class: 'someclass'}, ...]` + # Returns an empty array when no decorations are found + decorationsForBufferRow: (bufferRow, decorationType) -> + @displayBuffer.decorationsForBufferRow(bufferRow, decorationType) + + # Public: Get all the decorations for a range of buffer rows (inclusive) + # + # startBufferRow - the {int} start of the buffer row range + # endBufferRow - the {int} end of the buffer row range (inclusive) + # decorationType - the {String} decoration type to filter by eg. 'gutter' + # + # Returns an {Object} of decorations in the form `{23: [{type: 'gutter', class: 'someclass'}, ...], 24: [...]}` + # Returns an {Object} with keyed with all buffer rows in the range containing empty {Array}s when no decorations are found + decorationsForBufferRowRange: (startBufferRow, endBufferRow, decorationType) -> + @displayBuffer.decorationsForBufferRowRange(startBufferRow, endBufferRow, decorationType) + + # Public: Adds a decoration to a buffer row. For example, use to mark a gutter + # line number with a class by using the form `{type: 'gutter', class: 'linter-error'}` + # + # bufferRow - the {int} buffer row + # decoration - the {Object} decoration type to filter by eg. `{type: 'gutter', class: 'linter-error'}` + # + # Returns nothing + addDecorationToBufferRow: (bufferRow, decoration) -> + @displayBuffer.addDecorationToBufferRow(bufferRow, decoration) + + # Public: Removes a decoration from a buffer row. + # + # ```coffee + # editor.removeDecorationFromBufferRow(2, {type: 'gutter', class: 'linter-error'}) + # ``` + # + # All decorations matching a pattern will be removed. For example, you might + # have decorations with a namespace like this attached to a row: + # + # ```coffee + # [ + # {type: 'gutter', namespace: 'myns', class: 'something'}, + # {type: 'gutter', namespace: 'myns', class: 'something-else'} + # ] + # ``` + # + # You can remove both with: + # + # ```coffee + # editor.removeDecorationFromBufferRow(2, {namespace: 'myns'}) + # ``` + # + # bufferRow - the {int} buffer row + # decorationPattern - the {Object} decoration type to filter by eg. `{type: 'gutter', class: 'linter-error'}` + # + # Returns an {Array} of the removed decorations + removeDecorationFromBufferRow: (bufferRow, decorationPattern) -> + @displayBuffer.removeDecorationFromBufferRow(bufferRow, decorationPattern) + + # Public: Adds a decoration to line numbers in a buffer row range + # + # startBufferRow - the {int} start of the buffer row range + # endBufferRow - the {int} end of the buffer row range (inclusive) + # decoration - the {Object} decoration type to filter by eg. `{type: 'gutter', class: 'linter-error'}` + # + # Returns nothing + addDecorationToBufferRowRange: (startBufferRow, endBufferRow, decoration) -> + @displayBuffer.addDecorationToBufferRowRange(startBufferRow, endBufferRow, decoration) + + # Public: Removes a decoration from line numbers in a buffer row range + # + # startBufferRow - the {int} start of the buffer row range + # endBufferRow - the {int} end of the buffer row range (inclusive) + # decoration - the {Object} decoration type to filter by eg. `{type: 'gutter', class: 'linter-error'}` + # + # Returns nothing + removeDecorationFromBufferRowRange: (startBufferRow, endBufferRow, decoration) -> + @displayBuffer.removeDecorationFromBufferRowRange(startBufferRow, endBufferRow, decoration) + + # Public: Adds a decoration that tracks a {Marker}. When the marker moves, + # is invalidated, or is destroyed, the decoration will be updated to reflect the marker's state. + # + # marker - the {Marker} you want this decoration to follow + # decoration - the {Object} decoration type to filter by eg. `{type: 'gutter', class: 'linter-error'}` + # + # Returns nothing + addDecorationForMarker: (marker, decoration) -> + @displayBuffer.addDecorationForMarker(marker, decoration) + + # Public: Removes all decorations associated with a {Marker} that match a + # `decorationPattern` and stop tracking the {Marker}. + # + # marker - the {Marker} to detach from + # decorationPattern - the {Object} decoration type to filter by eg. `{type: 'gutter', class: 'linter-error'}` + # + # Returns nothing + removeDecorationForMarker: (marker, decorationPattern) -> + @displayBuffer.removeDecorationForMarker(marker, decorationPattern) + # Public: Get the {DisplayBufferMarker} for the given marker id. getMarker: (id) -> @displayBuffer.getMarker(id) @@ -1162,6 +1263,7 @@ class Editor extends Model addCursor: (marker) -> cursor = new Cursor(editor: this, marker: marker) @cursors.push(cursor) + @addDecorationForMarker(marker, {class: 'cursor-line'}) @emit 'cursor-added', cursor cursor diff --git a/src/gutter-component.coffee b/src/gutter-component.coffee index 72581cd0a..87f96dd76 100644 --- a/src/gutter-component.coffee +++ b/src/gutter-component.coffee @@ -1,3 +1,4 @@ +_ = require 'underscore-plus' React = require 'react-atom-fork' {div} = require 'reactionary-atom-fork' {isEqual, isEqualForProperties, multiplyString, toArray} = require 'underscore-plus' @@ -15,7 +16,7 @@ GutterComponent = React.createClass render: -> {scrollHeight, scrollTop} = @props - div className: 'gutter', + div className: 'gutter', onClick: @onClick, div className: 'line-numbers', ref: 'lineNumbers', style: height: scrollHeight WebkitTransform: "translate3d(0px, #{-scrollTop}px, 0px)" @@ -24,6 +25,7 @@ GutterComponent = React.createClass @lineNumberNodesById = {} @lineNumberIdsByScreenRow = {} @screenRowsByLineNumberId = {} + @previousDecorations = {} componentDidMount: -> @appendDummyLineNumber() @@ -36,10 +38,12 @@ GutterComponent = React.createClass 'renderedRowRange', 'scrollTop', 'lineHeightInPixels', 'mouseWheelScreenRow' ) - {renderedRowRange, pendingChanges} = newProps + {renderedRowRange, pendingChanges, decorations} = newProps for change in pendingChanges when Math.abs(change.screenDelta) > 0 or Math.abs(change.bufferDelta) > 0 return true unless change.end <= renderedRowRange.start or renderedRowRange.end <= change.start + return true unless _.isEqual(@previousDecorations, decorations) + false componentDidUpdate: (oldProps) -> @@ -70,7 +74,7 @@ GutterComponent = React.createClass @removeLineNumberNodes(lineNumberIdsToPreserve) appendOrUpdateVisibleLineNumberNodes: -> - {editor, renderedRowRange, scrollTop, maxLineNumberDigits} = @props + {editor, renderedRowRange, scrollTop, maxLineNumberDigits, decorations} = @props [startRow, endRow] = renderedRowRange newLineNumberIds = null @@ -91,12 +95,12 @@ GutterComponent = React.createClass visibleLineNumberIds.add(id) if @hasLineNumberNode(id) - @updateLineNumberNode(id, screenRow) + @updateLineNumberNode(id, bufferRow, screenRow, wrapCount > 0, decorations[bufferRow]) else newLineNumberIds ?= [] newLineNumbersHTML ?= "" newLineNumberIds.push(id) - newLineNumbersHTML += @buildLineNumberHTML(bufferRow, wrapCount > 0, maxLineNumberDigits, screenRow) + newLineNumbersHTML += @buildLineNumberHTML(bufferRow, wrapCount > 0, maxLineNumberDigits, screenRow, decorations[bufferRow]) @screenRowsByLineNumberId[id] = screenRow @lineNumberIdsByScreenRow[screenRow] = id @@ -110,6 +114,7 @@ GutterComponent = React.createClass @lineNumberNodesById[lineNumberId] = lineNumberNode node.appendChild(lineNumberNode) + @previousDecorations = decorations visibleLineNumberIds removeLineNumberNodes: (lineNumberIdsToPreserve) -> @@ -123,7 +128,7 @@ GutterComponent = React.createClass delete @screenRowsByLineNumberId[lineNumberId] node.removeChild(lineNumberNode) - buildLineNumberHTML: (bufferRow, softWrapped, maxLineNumberDigits, screenRow) -> + buildLineNumberHTML: (bufferRow, softWrapped, maxLineNumberDigits, screenRow, decorations) -> if screenRow? {lineHeightInPixels} = @props style = "position: absolute; top: #{screenRow * lineHeightInPixels}px;" @@ -131,7 +136,13 @@ GutterComponent = React.createClass style = "visibility: hidden;" innerHTML = @buildLineNumberInnerHTML(bufferRow, softWrapped, maxLineNumberDigits) - "