diff --git a/spec/text-editor-presenter-spec.coffee b/spec/text-editor-presenter-spec.coffee index fafa87b19..130e9fba2 100644 --- a/spec/text-editor-presenter-spec.coffee +++ b/spec/text-editor-presenter-spec.coffee @@ -20,7 +20,7 @@ describe "TextEditorPresenter", -> expectValues = (actual, expected) -> for key, value of expected - expect(actual[key]).toBe value + expect(actual[key]).toEqual value expectStateUpdate = (presenter, fn) -> updatedState = false @@ -578,3 +578,228 @@ describe "TextEditorPresenter", -> advanceClock(cursorBlinkResumeDelay) advanceClock(cursorBlinkPeriod / 2) expect(presenter.state.content.blinkCursorsOff).toBe true + + describe ".highlights", -> + stateForHighlight = (presenter, decoration) -> + presenter.state.content.highlights[decoration.id] + + stateForSelection = (presenter, selectionIndex) -> + selection = presenter.model.getSelections()[selectionIndex] + stateForHighlight(presenter, selection.decoration) + + 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 + marker4 = editor.markBufferRange([[2, 6], [4, 6]]) + highlight4 = editor.decorateMarker(marker4, type: 'highlight', class: 'd') + + # partially off-screen below, 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') + + presenter = new TextEditorPresenter(model: editor, clientHeight: 30, scrollTop: 20, lineHeight: 10, lineOverdrawMargin: 0, baseCharacterWidth: 10) + + expect(stateForHighlight(presenter, highlight1)).toBeUndefined() + + expectValues stateForHighlight(presenter, highlight2), { + class: 'b' + regions: [ + {top: 2 * 10, left: 0 * 10, width: 6 * 10, height: 1 * 10} + ] + } + + expectValues stateForHighlight(presenter, highlight3), { + class: 'c' + regions: [ + {top: 2 * 10, left: 0 * 10, right: 0, height: 1 * 10} + {top: 3 * 10, left: 0 * 10, width: 6 * 10, height: 1 * 10} + ] + } + + expectValues stateForHighlight(presenter, highlight4), { + class: 'd' + regions: [ + {top: 2 * 10, left: 6 * 10, right: 0, height: 1 * 10} + {top: 3 * 10, left: 0, right: 0, height: 1 * 10} + {top: 4 * 10, left: 0, width: 6 * 10, height: 1 * 10} + ] + } + + expectValues stateForHighlight(presenter, highlight5), { + class: 'e' + regions: [ + {top: 3 * 10, left: 6 * 10, right: 0, height: 1 * 10} + {top: 4 * 10, left: 0 * 10, right: 0, height: 2 * 10} + ] + } + + expectValues stateForHighlight(presenter, highlight6), { + class: 'f' + regions: [ + {top: 5 * 10, left: 6 * 10, right: 0, height: 1 * 10} + ] + } + + expect(stateForHighlight(presenter, highlight7)).toBeUndefined() + expect(stateForHighlight(presenter, highlight8)).toBeUndefined() + + it "updates when ::scrollTop changes", -> + editor.setSelectedBufferRanges([ + [[6, 2], [6, 4]], + ]) + + presenter = new TextEditorPresenter(model: editor, clientHeight: 30, scrollTop: 20, lineHeight: 10, lineOverdrawMargin: 0, baseCharacterWidth: 10) + + expect(stateForSelection(presenter, 0)).toBeUndefined() + expectStateUpdate presenter, -> presenter.setScrollTop(5 * 10) + expect(stateForSelection(presenter, 0)).toBeDefined() + expectStateUpdate presenter, -> presenter.setScrollTop(2 * 10) + expect(stateForSelection(presenter, 0)).toBeUndefined() + + it "updates when ::clientHeight changes", -> + editor.setSelectedBufferRanges([ + [[6, 2], [6, 4]], + ]) + + presenter = new TextEditorPresenter(model: editor, clientHeight: 20, scrollTop: 20, lineHeight: 10, lineOverdrawMargin: 0, baseCharacterWidth: 10) + + expect(stateForSelection(presenter, 0)).toBeUndefined() + expectStateUpdate presenter, -> presenter.setClientHeight(60) + expect(stateForSelection(presenter, 0)).toBeDefined() + expectStateUpdate presenter, -> presenter.setClientHeight(20) + expect(stateForSelection(presenter, 0)).toBeUndefined() + + it "updates when ::lineHeight changes", -> + editor.setSelectedBufferRanges([ + [[2, 2], [2, 4]], + [[3, 4], [3, 6]], + ]) + + presenter = new TextEditorPresenter(model: editor, clientHeight: 20, scrollTop: 0, lineHeight: 10, lineOverdrawMargin: 0, baseCharacterWidth: 10) + + expectValues stateForSelection(presenter, 0), { + regions: [ + {top: 2 * 10, left: 2 * 10, width: 2 * 10, height: 10} + ] + } + expect(stateForSelection(presenter, 1)).toBeUndefined() + + expectStateUpdate presenter, -> presenter.setLineHeight(5) + + expectValues stateForSelection(presenter, 0), { + regions: [ + {top: 2 * 5, left: 2 * 10, width: 2 * 10, height: 5} + ] + } + + expectValues stateForSelection(presenter, 1), { + regions: [ + {top: 3 * 5, left: 4 * 10, width: 2 * 10, height: 5} + ] + } + + it "updates when ::baseCharacterWidth changes", -> + editor.setSelectedBufferRanges([ + [[2, 2], [2, 4]], + ]) + + presenter = new TextEditorPresenter(model: editor, clientHeight: 20, scrollTop: 0, lineHeight: 10, lineOverdrawMargin: 0, baseCharacterWidth: 10) + + expectValues stateForSelection(presenter, 0), { + regions: [{top: 2 * 10, left: 2 * 10, width: 2 * 10, height: 10}] + } + expectStateUpdate presenter, -> presenter.setBaseCharacterWidth(20) + expectValues stateForSelection(presenter, 0), { + regions: [{top: 2 * 10, left: 2 * 20, width: 2 * 20, height: 10}] + } + + it "updates when scoped character widths change", -> + waitsForPromise -> + atom.packages.activatePackage('language-javascript') + + runs -> + editor.setSelectedBufferRanges([ + [[2, 4], [2, 6]], + ]) + + presenter = new TextEditorPresenter(model: editor, clientHeight: 20, scrollTop: 0, lineHeight: 10, lineOverdrawMargin: 0, baseCharacterWidth: 10) + + expectValues stateForSelection(presenter, 0), { + regions: [{top: 2 * 10, left: 4 * 10, width: 2 * 10, height: 10}] + } + expectStateUpdate presenter, -> presenter.setScopedCharWidth(['source.js', 'keyword.control.js'], 'i', 20) + expectValues stateForSelection(presenter, 0), { + regions: [{top: 2 * 10, 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 = new TextEditorPresenter(model: editor, clientHeight: 20, scrollTop: 0, lineHeight: 10, lineOverdrawMargin: 0, baseCharacterWidth: 10) + + expectValues stateForSelection(presenter, 0), { + regions: [{top: 1 * 10, left: 2 * 10, width: 2 * 10, height: 10}] + } + expect(stateForSelection(presenter, 1)).toBeUndefined() + + # moving into view + expectStateUpdate presenter, -> editor.getSelections()[1].setBufferRange([[2, 4], [2, 6]]) + expectValues stateForSelection(presenter, 1), { + regions: [{top: 2 * 10, left: 4 * 10, width: 2 * 10, height: 10}] + } + + # becoming empty + expectStateUpdate presenter, -> editor.getSelections()[1].clear() + expect(stateForSelection(presenter, 1)).toBeUndefined() + + # becoming non-empty + expectStateUpdate presenter, -> editor.getSelections()[1].setBufferRange([[2, 4], [2, 6]]) + expectValues stateForSelection(presenter, 1), { + regions: [{top: 2 * 10, left: 4 * 10, width: 2 * 10, height: 10}] + } + + # moving out of view + expectStateUpdate presenter, -> editor.getSelections()[1].setBufferRange([[3, 4], [3, 6]]) + expect(stateForSelection(presenter, 1)).toBeUndefined() + + # adding + expectStateUpdate presenter, -> editor.addSelectionForBufferRange([[1, 4], [1, 6]]) + expectValues stateForSelection(presenter, 2), { + regions: [{top: 1 * 10, left: 4 * 10, width: 2 * 10, height: 10}] + } + + # moving added selection + expectStateUpdate presenter, -> editor.getSelections()[2].setBufferRange([[1, 4], [1, 8]]) + expectValues stateForSelection(presenter, 2), { + regions: [{top: 1 * 10, left: 4 * 10, width: 4 * 10, height: 10}] + } + + # destroying + destroyedSelection = editor.getSelections()[2] + expectStateUpdate presenter, -> destroyedSelection.destroy() + expect(stateForHighlight(presenter, destroyedSelection.decoration)).toBeUndefined() diff --git a/src/text-editor-presenter.coffee b/src/text-editor-presenter.coffee index 463df13f3..cd5ae92b6 100644 --- a/src/text-editor-presenter.coffee +++ b/src/text-editor-presenter.coffee @@ -29,34 +29,25 @@ class TextEditorPresenter @disposables.add @model.onDidChangeMini(@updateLinesState.bind(this)) @disposables.add @model.onDidAddDecoration(@didAddDecoration.bind(this)) @disposables.add @model.onDidAddCursor(@didAddCursor.bind(this)) - @observeDecoration(decoration) for decoration in @model.getLineDecorations() + @observeLineDecoration(decoration) for decoration in @model.getLineDecorations() + @observeHighlightDecoration(decoration) for decoration in @model.getHighlightDecorations() @observeCursor(cursor) for cursor in @model.getCursors() observeConfig: -> @disposables.add atom.config.onDidChange 'editor.showIndentGuide', scope: @model.getRootScopeDescriptor(), @updateContentState.bind(this) buildState: -> - @state = {} - @buildContentState() - @buildLinesState() - @buildCursorsState() - - buildContentState: -> - @state.content = {} - @updateContentState() - - buildLinesState: -> - @state.content.lines = {} - @updateLinesState() - - buildCursorsState: -> - @state.content.blinkCursorsOff = false - @state.content.cursors = {} - @updateCursorsState() + @state = + content: + lines: {} + blinkCursorsOff: false + @updateState() updateState: -> @updateContentState() @updateLinesState() + @updateCursorsState() + @updateHighlightsState() updateContentState: -> @state.content.scrollWidth = @computeScrollWidth() @@ -108,20 +99,79 @@ class TextEditorPresenter updateCursorsState: -> startRow = @getStartRow() endRow = @getEndRow() - visibleCursors = {} + @state.content.cursors = {} for cursor in @model.getCursors() if cursor.isVisible() and startRow <= cursor.getScreenRow() < endRow pixelRect = @pixelRectForScreenRange(cursor.getScreenRange()) pixelRect.width = @getBaseCharacterWidth() if pixelRect.width is 0 @state.content.cursors[cursor.id] = pixelRect - visibleCursors[cursor.id] = true - - for id of @state.content.cursors - delete @state.content.cursors[id] unless visibleCursors.hasOwnProperty(id) @emitter.emit 'did-update-state' + updateHighlightsState: -> + startRow = @getStartRow() + endRow = @getEndRow() + @state.content.highlights = {} + + for decoration in @model.getHighlightDecorations() + screenRange = decoration.getMarker().getScreenRange() + if screenRange.intersectsRowRange(startRow, endRow - 1) + if screenRange.start.row < startRow + screenRange.start.row = startRow + screenRange.start.column = 0 + if screenRange.end.row >= endRow + screenRange.end.row = endRow + screenRange.end.column = 0 + continue if screenRange.isEmpty() + @state.content.highlights[decoration.id] = + class: decoration.getProperties().class + regions: @buildHighlightRegions(screenRange) + + buildHighlightRegions: (screenRange) -> + lineHeightInPixels = @getLineHeight() + startPixelPosition = @pixelPositionForScreenPosition(screenRange.start, true) + endPixelPosition = @pixelPositionForScreenPosition(screenRange.end, true) + spannedRows = screenRange.end.row - screenRange.start.row + 1 + + if spannedRows is 1 + [ + top: startPixelPosition.top + height: lineHeightInPixels + left: startPixelPosition.left + width: endPixelPosition.left - startPixelPosition.left + ] + else + regions = [] + + # 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 + regions.push( + top: endPixelPosition.top + height: lineHeightInPixels + left: 0 + width: endPixelPosition.left + ) + + regions + getStartRow: -> startRow = Math.floor(@getScrollTop() / @getLineHeight()) - @lineOverdrawMargin Math.max(0, startRow) @@ -168,6 +218,7 @@ class TextEditorPresenter @updateContentState() @updateLinesState() @updateCursorsState() + @updateHighlightsState() getScrollTop: -> @scrollTop @@ -179,6 +230,7 @@ class TextEditorPresenter setClientHeight: (@clientHeight) -> @updateLinesState() @updateCursorsState() + @updateHighlightsState() getClientHeight: -> @clientHeight ? @model.getScreenLineCount() * @getLineHeight() @@ -193,13 +245,12 @@ class TextEditorPresenter @updateContentState() @updateLinesState() @updateCursorsState() + @updateHighlightsState() getLineHeight: -> @lineHeight setBaseCharacterWidth: (@baseCharacterWidth) -> - @updateContentState() - @updateLinesState() - @updateCursorsState() + @characterWidthsChanged() getBaseCharacterWidth: -> @baseCharacterWidth @@ -230,6 +281,7 @@ class TextEditorPresenter @updateContentState() @updateLinesState() @updateCursorsState() + @updateHighlightsState() clearScopedCharWidths: -> @charWidthsByScope = {} @@ -278,7 +330,7 @@ class TextEditorPresenter {top, left, width, height} - observeDecoration: (decoration) -> + observeLineDecoration: (decoration) -> markerDidChangeDisposable = decoration.getMarker().onDidChange(@updateLinesState.bind(this)) didDestroyDisposable = decoration.onDidDestroy => @disposables.remove(markerDidChangeDisposable) @@ -288,10 +340,23 @@ class TextEditorPresenter @disposables.add(markerDidChangeDisposable) @disposables.add(didDestroyDisposable) + observeHighlightDecoration: (decoration) -> + markerDidChangeDisposable = decoration.getMarker().onDidChange(@updateHighlightsState.bind(this)) + didDestroyDisposable = decoration.onDidDestroy => + @disposables.remove(markerDidChangeDisposable) + @disposables.remove(didDestroyDisposable) + @updateHighlightsState() + + @disposables.add(markerDidChangeDisposable) + @disposables.add(didDestroyDisposable) + didAddDecoration: (decoration) -> if decoration.isType('line') - @observeDecoration(decoration) + @observeLineDecoration(decoration) @updateLinesState() + else if decoration.isType('highlight') + @observeHighlightDecoration(decoration) + @updateHighlightsState() observeCursor: (cursor) -> didChangePositionDisposable = cursor.onDidChangePosition =>