diff --git a/spec/lines-yardstick-spec.coffee b/spec/lines-yardstick-spec.coffee new file mode 100644 index 00000000000..48d446f2362 --- /dev/null +++ b/spec/lines-yardstick-spec.coffee @@ -0,0 +1,101 @@ +LinesYardstick = require '../src/lines-yardstick' +{Point} = require 'text-buffer' + +describe "LinesYardstick", -> + [linesYardstick, editor, styleNodesToRemove] = [] + + styleSheetWithSelectorAndFont = (selector, font) -> + styleNode = document.createElement("style") + styleNode.innerHTML = """ + #{selector} { + font: #{font}; + } + """ + document.body.appendChild(styleNode) + styleNodesToRemove ?= [] + styleNodesToRemove.push(styleNode) + styleNode + + cleanupStyleSheets = -> + styleNode.remove() while styleNode = styleNodesToRemove?.pop() + + beforeEach -> + waitsForPromise -> + atom.packages.activatePackage('language-javascript') + + waitsForPromise -> + atom.project.open('sample.js').then (o) -> editor = o + + runs -> + linesYardstick = new LinesYardstick(editor) + document.body.appendChild(linesYardstick.getDomNode()) + + waitsFor -> + linesYardstick.canMeasure() + + afterEach -> + linesYardstick.getDomNode().remove() + cleanupStyleSheets() + + describe "::buildDomNodesForScreenRows(screenRows)", -> + it "asks for a line HTML only once", -> + requestedLinesByScreenRow = {} + linesYardstick.setLineHtmlProvider (screenRow, line) -> + requestedLinesByScreenRow[screenRow] ?= 0 + requestedLinesByScreenRow[screenRow]++ + + "
" + + linesYardstick.buildDomNodesForScreenRows([0, 1, 2]) + linesYardstick.buildDomNodesForScreenRows([1, 2, 3]) + linesYardstick.buildDomNodesForScreenRows([3, 4, 5]) + + expect(Object.keys(requestedLinesByScreenRow).length).not.toBe(0) + for screenRow, requestsCount of requestedLinesByScreenRow + expect(requestsCount).toBe(1) + + describe "::leftPixelPositionForScreenPosition(point)", -> + it "measure positions based on stylesheets and default font", -> + editor.setText("hello\nworld\n") + linesYardstick.setDefaultFont("monospace", "14px") + linesYardstick.setLineHtmlProvider (screenRow, line) -> + if screenRow is 0 + "
hello
" + else if screenRow is 1 + "
world
" + else + throw new Error("This screen row shouldn't have been requested.") + + linesYardstick.buildDomNodesForScreenRows([0, 1]) + + conversionTable = [ + [new Point(0, 0), {left: 0, top: editor.getLineHeightInPixels() * 0}] + [new Point(0, 1), {left: 8, top: editor.getLineHeightInPixels() * 0}] + [new Point(0, 3), {left: 24, top: editor.getLineHeightInPixels() * 0}] + [new Point(1, 0), {left: 0, top: editor.getLineHeightInPixels() * 1}] + [new Point(1, 1), {left: 8, top: editor.getLineHeightInPixels() * 1}] + [new Point(1, 4), {left: 32, top: editor.getLineHeightInPixels() * 1}] + ] + + for [point, position] in conversionTable + expect( + linesYardstick.pixelPositionForScreenPosition(point) + ).toEqual(position) + + linesYardstick.resetStyleSheets([ + styleSheetWithSelectorAndFont(".bigger", "16px monospace") + ]) + + conversionTable = [ + [new Point(0, 0), {left: 0, top: 0 * editor.getLineHeightInPixels()}] + [new Point(0, 1), {left: 8, top: 0 * editor.getLineHeightInPixels()}] + [new Point(0, 3), {left: 26, top: 0 * editor.getLineHeightInPixels()}] + [new Point(1, 0), {left: 0, top: 1 * editor.getLineHeightInPixels()}] + [new Point(1, 1), {left: 8, top: 1 * editor.getLineHeightInPixels()}] + [new Point(1, 4), {left: 32, top: 1 * editor.getLineHeightInPixels()}] + ] + + for [point, position] in conversionTable + expect( + linesYardstick.pixelPositionForScreenPosition(point) + ).toEqual(position) diff --git a/spec/text-editor-component-spec.coffee b/spec/text-editor-component-spec.coffee index 84d8cc1c71b..ac71e061151 100644 --- a/spec/text-editor-component-spec.coffee +++ b/spec/text-editor-component-spec.coffee @@ -5,7 +5,7 @@ TextEditorView = require '../src/text-editor-view' TextEditorComponent = require '../src/text-editor-component' nbsp = String.fromCharCode(160) -describe "TextEditorComponent", -> +fdescribe "TextEditorComponent", -> [contentNode, editor, wrapperView, wrapperNode, component, componentNode, verticalScrollbarNode, horizontalScrollbarNode] = [] [lineHeightInPixels, charWidth, nextAnimationFrame, noAnimationFrame, tileSize, tileHeightInPixels] = [] @@ -40,6 +40,11 @@ describe "TextEditorComponent", -> wrapperNode.setUpdatedSynchronously(false) {component} = wrapperView + + waitsFor -> + component.linesYardstick.canMeasure() + + runs -> component.setFontFamily('monospace') component.setLineHeight(1.3) component.setFontSize(20) @@ -77,6 +82,37 @@ describe "TextEditorComponent", -> expect(component.lineNodeForScreenRow(0).textContent).not.toBe("You shouldn't see this update.") + describe "measurements", -> + it "is equivalent to TextEditorPresenter::pixelPositionForScreenPosition", -> + screenRows = new Set([0...editor.getScreenLineCount()]) + component.prepareScreenRowsForMeasurement(screenRows) + + screenRows.forEach (screenRow) -> + length = editor.tokenizedLineForScreenRow(screenRow).getMaxScreenColumn() + + for screenColumn in [0..length] by 1 + point = [screenRow, screenColumn] + actual = component.pixelPositionForScreenPosition(point) + expected = component.presenter.pixelPositionForScreenPosition(point) + + expect(expected).toEqual(actual) + + component.setFontSize(14) + component.measureDimensions() + nextAnimationFrame() + + component.prepareScreenRowsForMeasurement(screenRows) + + screenRows.forEach (screenRow) -> + length = editor.tokenizedLineForScreenRow(screenRow).getMaxScreenColumn() + + for screenColumn in [0..length] by 1 + point = [screenRow, screenColumn] + actual = component.pixelPositionForScreenPosition(point) + expected = component.presenter.pixelPositionForScreenPosition(point) + + expect(expected).toEqual(actual) + describe "line rendering", -> expectTileContainsRow = (tileNode, screenRow, {top}) -> lineNode = tileNode.querySelector("[data-screen-row='#{screenRow}']") diff --git a/spec/text-editor-presenter-spec.coffee b/spec/text-editor-presenter-spec.coffee index f859a37a0ac..d7c2d44de65 100644 --- a/spec/text-editor-presenter-spec.coffee +++ b/spec/text-editor-presenter-spec.coffee @@ -4,12 +4,13 @@ TextBuffer = require 'text-buffer' {Point, Range} = TextBuffer TextEditor = require '../src/text-editor' TextEditorPresenter = require '../src/text-editor-presenter' +LinesYardstick = require '../src/lines-yardstick' describe "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 "::getState()", -> - [buffer, editor] = [] + [buffer, editor, linesYardstick] = [] beforeEach -> # These *should* be mocked in the spec helper, but changing that now would break packages :-( @@ -18,11 +19,20 @@ describe "TextEditorPresenter", -> buffer = new TextBuffer(filePath: require.resolve('./fixtures/sample.js')) editor = new TextEditor({buffer}) + linesYardstick = new LinesYardstick(editor) + linesYardstick.setDefaultFont("monospace", "16px") + linesYardstick.setLineHtmlProvider (screenRow, line) -> + "
#{line.text}
" + document.body.appendChild(linesYardstick.getDomNode()) + waitsForPromise -> buffer.load() + waitsFor -> linesYardstick.canMeasure() + afterEach -> editor.destroy() buffer.destroy() + linesYardstick.getDomNode().remove() buildPresenter = (params={}) -> _.defaults params, @@ -39,6 +49,7 @@ describe "TextEditorPresenter", -> verticalScrollbarWidth: 10 scrollTop: 0 scrollLeft: 0 + linesYardstick: linesYardstick new TextEditorPresenter(params) @@ -289,25 +300,16 @@ describe "TextEditorPresenter", -> expectStateUpdate presenter, -> presenter.setContentFrameWidth(10 * maxLineLength + 20) expect(presenter.getState().horizontalScrollbar.scrollWidth).toBe 10 * maxLineLength + 20 - it "updates when the ::baseCharacterWidth changes", -> + it "updates when character widths change", -> maxLineLength = editor.getMaxScreenLineLength() presenter = buildPresenter(contentFrameWidth: 50, baseCharacterWidth: 10) expect(presenter.getState().horizontalScrollbar.scrollWidth).toBe 10 * maxLineLength + 1 - expectStateUpdate presenter, -> presenter.setBaseCharacterWidth(15) + expectStateUpdate presenter, -> + linesYardstick.setDefaultFont("monospace", "25px") + presenter.characterWidthsChanged() expect(presenter.getState().horizontalScrollbar.scrollWidth).toBe 15 * maxLineLength + 1 - it "updates when the scoped character widths change", -> - waitsForPromise -> atom.packages.activatePackage('language-javascript') - - runs -> - maxLineLength = editor.getMaxScreenLineLength() - presenter = buildPresenter(contentFrameWidth: 50, baseCharacterWidth: 10) - - expect(presenter.getState().horizontalScrollbar.scrollWidth).toBe 10 * maxLineLength + 1 - expectStateUpdate presenter, -> presenter.setScopedCharacterWidth(['source.js', 'support.function.js'], 'p', 20) - expect(presenter.getState().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(presenter.getState().horizontalScrollbar.scrollWidth).toBe 10 * editor.getMaxScreenLineLength() + 1 @@ -533,12 +535,11 @@ describe "TextEditorPresenter", -> presenter = buildPresenter() expect(presenter.getState().hiddenInput.width).toBe 10 - expectStateUpdate presenter, -> presenter.setBaseCharacterWidth(15) + expectStateUpdate presenter, -> + linesYardstick.setDefaultFont("monospace", "25px") + presenter.characterWidthsChanged() expect(presenter.getState().hiddenInput.width).toBe 15 - expectStateUpdate presenter, -> presenter.setScopedCharacterWidth(['source.js', 'storage.modifier.js'], 'r', 20) - expect(presenter.getState().hiddenInput.width).toBe 20 - it "is 2px at the end of lines", -> presenter = buildPresenter() editor.setCursorBufferPosition([3, Infinity]) @@ -623,25 +624,16 @@ describe "TextEditorPresenter", -> expectStateUpdate presenter, -> presenter.setContentFrameWidth(10 * maxLineLength + 20) expect(presenter.getState().content.scrollWidth).toBe 10 * maxLineLength + 20 - it "updates when the ::baseCharacterWidth changes", -> + it "updates when character widths changes", -> maxLineLength = editor.getMaxScreenLineLength() presenter = buildPresenter(contentFrameWidth: 50, baseCharacterWidth: 10) expect(presenter.getState().content.scrollWidth).toBe 10 * maxLineLength + 1 - expectStateUpdate presenter, -> presenter.setBaseCharacterWidth(15) + expectStateUpdate presenter, -> + linesYardstick.setDefaultFont("monospace", "25px") + presenter.characterWidthsChanged() expect(presenter.getState().content.scrollWidth).toBe 15 * maxLineLength + 1 - it "updates when the scoped character widths change", -> - waitsForPromise -> atom.packages.activatePackage('language-javascript') - - runs -> - maxLineLength = editor.getMaxScreenLineLength() - presenter = buildPresenter(contentFrameWidth: 50, baseCharacterWidth: 10) - - expect(presenter.getState().content.scrollWidth).toBe 10 * maxLineLength + 1 - expectStateUpdate presenter, -> presenter.setScopedCharacterWidth(['source.js', 'support.function.js'], 'p', 20) - expect(presenter.getState().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(presenter.getState().content.scrollWidth).toBe 10 * editor.getMaxScreenLineLength() + 1 @@ -1179,27 +1171,15 @@ describe "TextEditorPresenter", -> 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 ::baseCharacterWidth changes", -> + it "updates when character widths change", -> editor.setCursorBufferPosition([2, 4]) presenter = buildPresenter(explicitHeight: 20, scrollTop: 20) - expectStateUpdate presenter, -> presenter.setBaseCharacterWidth(20) + expectStateUpdate presenter, -> + linesYardstick.setDefaultFont("monospace", "33px") + presenter.characterWidthsChanged() expect(stateForCursor(presenter, 0)).toEqual {top: 0, left: 4 * 20, width: 20, height: 10} - it "updates when scoped character widths change", -> - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - runs -> - editor.setCursorBufferPosition([1, 4]) - presenter = buildPresenter(explicitHeight: 20) - - expectStateUpdate presenter, -> presenter.setScopedCharacterWidth(['source.js', 'storage.modifier.js'], 'v', 20) - expect(stateForCursor(presenter, 0)).toEqual {top: 1 * 10, left: (3 * 10) + 20, width: 10, height: 10} - - expectStateUpdate presenter, -> presenter.setScopedCharacterWidth(['source.js', 'storage.modifier.js'], 'r', 20) - 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.setSelectedBufferRanges([ [[1, 2], [1, 2]], @@ -1513,7 +1493,7 @@ describe "TextEditorPresenter", -> ] } - it "updates when ::baseCharacterWidth changes", -> + it "updates when character widths change", -> editor.setSelectedBufferRanges([ [[2, 2], [2, 4]], ]) @@ -1523,30 +1503,13 @@ describe "TextEditorPresenter", -> expectValues stateForSelectionInTile(presenter, 0, 2), { regions: [{top: 0, left: 2 * 10, width: 2 * 10, height: 10}] } - expectStateUpdate presenter, -> presenter.setBaseCharacterWidth(20) + expectStateUpdate presenter, -> + linesYardstick.setDefaultFont("monospace", "33px") + presenter.characterWidthsChanged() expectValues stateForSelectionInTile(presenter, 0, 2), { regions: [{top: 0, 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 = 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.setScopedCharacterWidth(['source.js', 'keyword.control.js'], 'i', 20) - 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]], @@ -1695,7 +1658,7 @@ describe "TextEditorPresenter", -> pixelPosition: {top: 3 * 10 - presenter.state.content.scrollTop, left: 13 * 10} } - it "updates when ::baseCharacterWidth changes", -> + it "updates when character widths change", -> scrollTop = 20 marker = editor.markBufferPosition([2, 13], invalidate: 'touch') decoration = editor.decorateMarker(marker, {type: 'overlay', item}) @@ -1706,7 +1669,9 @@ describe "TextEditorPresenter", -> pixelPosition: {top: 3 * 10 - scrollTop, left: 13 * 10} } - expectStateUpdate presenter, -> presenter.setBaseCharacterWidth(5) + expectStateUpdate presenter, -> + linesYardstick.setDefaultFont("monospace", "8px") + presenter.characterWidthsChanged() expectValues stateForOverlay(presenter, decoration), { item: item diff --git a/src/line-html-builder.coffee b/src/line-html-builder.coffee new file mode 100644 index 00000000000..2322b6d6b32 --- /dev/null +++ b/src/line-html-builder.coffee @@ -0,0 +1,180 @@ +TokenIterator = require './token-iterator' +TokenTextEscapeRegex = /[&"'<>]/g +MaxTokenLength = 20000 + +module.exports = +class LineHtmlBuilder + constructor: (@fastVersion) -> + @tokenIterator = new TokenIterator + + buildLineHTML: (indentGuidesVisible, width, lineState) -> + {screenRow, text, top, lineEnding, fold, isSoftWrapped, indentLevel, decorationClasses} = lineState + + return if text is "" and @fastVersion + + classes = '' + if decorationClasses? + for decorationClass in decorationClasses + classes += decorationClass + ' ' + classes += 'line' + + if @fastVersion + lineHTML = "
" + else + lineHTML = "
" + + if text is "" + lineHTML += @buildEmptyLineInnerHTML(indentGuidesVisible, lineState) + else + lineHTML += @buildLineInnerHTML(indentGuidesVisible, lineState) + + lineHTML += '' if fold and not @fastVersion + + lineHTML += "
" + lineHTML + + buildEmptyLineInnerHTML: (indentGuidesVisible, lineState) -> + {indentLevel, tabLength, endOfLineInvisibles} = lineState + + if indentGuidesVisible and indentLevel > 0 + invisibleIndex = 0 + lineHTML = '' + for i in [0...indentLevel] + lineHTML += "" + for j in [0...tabLength] + if invisible = endOfLineInvisibles?[invisibleIndex++] + lineHTML += "#{invisible}" + else + lineHTML += ' ' + lineHTML += "" + + while invisibleIndex < endOfLineInvisibles?.length + lineHTML += "#{endOfLineInvisibles[invisibleIndex++]}" + + lineHTML + else + @buildEndOfLineHTML(lineState) or ' ' + + buildLineInnerHTML: (indentGuidesVisible, lineState) -> + {firstNonWhitespaceIndex, firstTrailingWhitespaceIndex, invisibles} = lineState + lineIsWhitespaceOnly = firstTrailingWhitespaceIndex is 0 + + innerHTML = "" + @tokenIterator.reset(lineState) + + while @tokenIterator.next() + for scope in @tokenIterator.getScopeEnds() + innerHTML += "" + + for scope in @tokenIterator.getScopeStarts() + innerHTML += "" + + tokenStart = @tokenIterator.getScreenStart() + tokenEnd = @tokenIterator.getScreenEnd() + tokenText = @tokenIterator.getText() + isHardTab = @tokenIterator.isHardTab() + + if hasLeadingWhitespace = tokenStart < firstNonWhitespaceIndex + tokenFirstNonWhitespaceIndex = firstNonWhitespaceIndex - tokenStart + else + tokenFirstNonWhitespaceIndex = null + + if hasTrailingWhitespace = tokenEnd > firstTrailingWhitespaceIndex + tokenFirstTrailingWhitespaceIndex = Math.max(0, firstTrailingWhitespaceIndex - tokenStart) + else + tokenFirstTrailingWhitespaceIndex = null + + hasIndentGuide = + indentGuidesVisible and + (hasLeadingWhitespace or lineIsWhitespaceOnly) + + hasInvisibleCharacters = + (invisibles?.tab and isHardTab) or + (invisibles?.space and (hasLeadingWhitespace or hasTrailingWhitespace)) + + innerHTML += @buildTokenHTML(tokenText, isHardTab, tokenFirstNonWhitespaceIndex, tokenFirstTrailingWhitespaceIndex, hasIndentGuide, hasInvisibleCharacters, tokenStart, tokenEnd) + + for scope in @tokenIterator.getScopeEnds() + innerHTML += "" + + for scope in @tokenIterator.getScopes() + innerHTML += "" + + innerHTML += @buildEndOfLineHTML(lineState) unless @fastVersion + innerHTML + + buildTokenHTML: (tokenText, isHardTab, firstNonWhitespaceIndex, firstTrailingWhitespaceIndex, hasIndentGuide, hasInvisibleCharacters, tokenStart, tokenEnd) -> + if isHardTab + classes = 'hard-tab' + classes += ' leading-whitespace' if firstNonWhitespaceIndex? + classes += ' trailing-whitespace' if firstTrailingWhitespaceIndex? + classes += ' indent-guide' if hasIndentGuide + classes += ' invisible-character' if hasInvisibleCharacters + return "#{@escapeTokenText(tokenText)}" + else + tokenText = tokenText.replace("\0", "") + + startIndex = 0 + endIndex = tokenText.length + + leadingHtml = '' + trailingHtml = '' + + if firstNonWhitespaceIndex? + leadingWhitespace = tokenText.substring(0, firstNonWhitespaceIndex) + + classes = 'leading-whitespace' + classes += ' indent-guide' if hasIndentGuide + classes += ' invisible-character' if hasInvisibleCharacters + + leadingHtml = "#{leadingWhitespace}" + startIndex = firstNonWhitespaceIndex + + if firstTrailingWhitespaceIndex? + tokenIsOnlyWhitespace = firstTrailingWhitespaceIndex is 0 + trailingWhitespace = tokenText.substring(firstTrailingWhitespaceIndex) + + unless trailingWhitespace is "" + classes = 'trailing-whitespace' + classes += ' indent-guide' if hasIndentGuide and not firstNonWhitespaceIndex? and tokenIsOnlyWhitespace + classes += ' invisible-character' if hasInvisibleCharacters + + trailingHtml = "#{trailingWhitespace}" + + endIndex = firstTrailingWhitespaceIndex + + html = leadingHtml + if tokenText.length > MaxTokenLength + while startIndex < endIndex + text = @escapeTokenText(tokenText, startIndex, startIndex + MaxTokenLength) + html += "#{text}" + startIndex += MaxTokenLength + else + text = @escapeTokenText(tokenText, startIndex, endIndex) + html += text + + html += trailingHtml + html + + escapeTokenText: (tokenText, startIndex, endIndex) -> + if startIndex? and endIndex? and startIndex > 0 or endIndex < tokenText.length + tokenText = tokenText.slice(startIndex, endIndex) + tokenText.replace(TokenTextEscapeRegex, @escapeTokenTextReplace) + + escapeTokenTextReplace: (match) -> + switch match + when '&' then '&' + when '"' then '"' + when "'" then ''' + when '<' then '<' + when '>' then '>' + else match + + buildEndOfLineHTML: (lineState) -> + {endOfLineInvisibles} = lineState + + html = '' + if endOfLineInvisibles? + for invisible in endOfLineInvisibles + html += "#{invisible}" + html diff --git a/src/lines-tile-component.coffee b/src/lines-tile-component.coffee index f823ea8ac5b..9e159caf198 100644 --- a/src/lines-tile-component.coffee +++ b/src/lines-tile-component.coffee @@ -1,11 +1,10 @@ _ = require 'underscore-plus' HighlightsComponent = require './highlights-component' -TokenIterator = require './token-iterator' AcceptFilter = {acceptNode: -> NodeFilter.FILTER_ACCEPT} WrapperDiv = document.createElement('div') -TokenTextEscapeRegex = /[&"'<>]/g -MaxTokenLength = 20000 +LineHtmlBuilder = require './line-html-builder' +TokenIterator = require './token-iterator' cloneObject = (object) -> clone = {} @@ -15,6 +14,7 @@ cloneObject = (object) -> module.exports = class LinesTileComponent constructor: ({@presenter, @id}) -> + @lineHtmlBuilder = new LineHtmlBuilder @tokenIterator = new TokenIterator @measuredLines = new Set @lineNodesByLineId = {} @@ -98,7 +98,11 @@ class LinesTileComponent newLineIds ?= [] newLinesHTML ?= "" newLineIds.push(id) - newLinesHTML += @buildLineHTML(id) + newLinesHTML += @lineHtmlBuilder.buildLineHTML( + @newState.indentGuidesVisible, + @newState.width, + lineState + ) @screenRowsByLineId[id] = lineState.screenRow @lineIdsByScreenRow[lineState.screenRow] = id @oldTileState.lines[id] = cloneObject(lineState) @@ -114,170 +118,6 @@ class LinesTileComponent return - buildLineHTML: (id) -> - {width} = @newState - {screenRow, tokens, text, top, lineEnding, fold, isSoftWrapped, indentLevel, decorationClasses} = @newTileState.lines[id] - - classes = '' - if decorationClasses? - for decorationClass in decorationClasses - classes += decorationClass + ' ' - classes += 'line' - - lineHTML = "
" - - if text is "" - lineHTML += @buildEmptyLineInnerHTML(id) - else - lineHTML += @buildLineInnerHTML(id) - - lineHTML += '' if fold - lineHTML += "
" - lineHTML - - buildEmptyLineInnerHTML: (id) -> - {indentGuidesVisible} = @newState - {indentLevel, tabLength, endOfLineInvisibles} = @newTileState.lines[id] - - if indentGuidesVisible and indentLevel > 0 - invisibleIndex = 0 - lineHTML = '' - for i in [0...indentLevel] - lineHTML += "" - for j in [0...tabLength] - if invisible = endOfLineInvisibles?[invisibleIndex++] - lineHTML += "#{invisible}" - else - lineHTML += ' ' - lineHTML += "" - - while invisibleIndex < endOfLineInvisibles?.length - lineHTML += "#{endOfLineInvisibles[invisibleIndex++]}" - - lineHTML - else - @buildEndOfLineHTML(id) or ' ' - - buildLineInnerHTML: (id) -> - lineState = @newTileState.lines[id] - {firstNonWhitespaceIndex, firstTrailingWhitespaceIndex, invisibles} = lineState - lineIsWhitespaceOnly = firstTrailingWhitespaceIndex is 0 - - innerHTML = "" - @tokenIterator.reset(lineState) - - while @tokenIterator.next() - for scope in @tokenIterator.getScopeEnds() - innerHTML += "" - - for scope in @tokenIterator.getScopeStarts() - innerHTML += "" - - tokenStart = @tokenIterator.getScreenStart() - tokenEnd = @tokenIterator.getScreenEnd() - tokenText = @tokenIterator.getText() - isHardTab = @tokenIterator.isHardTab() - - if hasLeadingWhitespace = tokenStart < firstNonWhitespaceIndex - tokenFirstNonWhitespaceIndex = firstNonWhitespaceIndex - tokenStart - else - tokenFirstNonWhitespaceIndex = null - - if hasTrailingWhitespace = tokenEnd > firstTrailingWhitespaceIndex - tokenFirstTrailingWhitespaceIndex = Math.max(0, firstTrailingWhitespaceIndex - tokenStart) - else - tokenFirstTrailingWhitespaceIndex = null - - hasIndentGuide = - @newState.indentGuidesVisible and - (hasLeadingWhitespace or lineIsWhitespaceOnly) - - hasInvisibleCharacters = - (invisibles?.tab and isHardTab) or - (invisibles?.space and (hasLeadingWhitespace or hasTrailingWhitespace)) - - innerHTML += @buildTokenHTML(tokenText, isHardTab, tokenFirstNonWhitespaceIndex, tokenFirstTrailingWhitespaceIndex, hasIndentGuide, hasInvisibleCharacters) - - for scope in @tokenIterator.getScopeEnds() - innerHTML += "" - - for scope in @tokenIterator.getScopes() - innerHTML += "" - - innerHTML += @buildEndOfLineHTML(id) - innerHTML - - buildTokenHTML: (tokenText, isHardTab, firstNonWhitespaceIndex, firstTrailingWhitespaceIndex, hasIndentGuide, hasInvisibleCharacters) -> - if isHardTab - classes = 'hard-tab' - classes += ' leading-whitespace' if firstNonWhitespaceIndex? - classes += ' trailing-whitespace' if firstTrailingWhitespaceIndex? - classes += ' indent-guide' if hasIndentGuide - classes += ' invisible-character' if hasInvisibleCharacters - return "#{@escapeTokenText(tokenText)}" - else - startIndex = 0 - endIndex = tokenText.length - - leadingHtml = '' - trailingHtml = '' - - if firstNonWhitespaceIndex? - leadingWhitespace = tokenText.substring(0, firstNonWhitespaceIndex) - - classes = 'leading-whitespace' - classes += ' indent-guide' if hasIndentGuide - classes += ' invisible-character' if hasInvisibleCharacters - - leadingHtml = "#{leadingWhitespace}" - startIndex = firstNonWhitespaceIndex - - if firstTrailingWhitespaceIndex? - tokenIsOnlyWhitespace = firstTrailingWhitespaceIndex is 0 - trailingWhitespace = tokenText.substring(firstTrailingWhitespaceIndex) - - classes = 'trailing-whitespace' - classes += ' indent-guide' if hasIndentGuide and not firstNonWhitespaceIndex? and tokenIsOnlyWhitespace - classes += ' invisible-character' if hasInvisibleCharacters - - trailingHtml = "#{trailingWhitespace}" - - endIndex = firstTrailingWhitespaceIndex - - html = leadingHtml - if tokenText.length > MaxTokenLength - while startIndex < endIndex - html += "" + @escapeTokenText(tokenText, startIndex, startIndex + MaxTokenLength) + "" - startIndex += MaxTokenLength - else - html += @escapeTokenText(tokenText, startIndex, endIndex) - - html += trailingHtml - html - - escapeTokenText: (tokenText, startIndex, endIndex) -> - if startIndex? and endIndex? and startIndex > 0 or endIndex < tokenText.length - tokenText = tokenText.slice(startIndex, endIndex) - tokenText.replace(TokenTextEscapeRegex, @escapeTokenTextReplace) - - escapeTokenTextReplace: (match) -> - switch match - when '&' then '&' - when '"' then '"' - when "'" then ''' - when '<' then '<' - when '>' then '>' - else match - - buildEndOfLineHTML: (id) -> - {endOfLineInvisibles} = @newTileState.lines[id] - - html = '' - if endOfLineInvisibles? - for invisible in endOfLineInvisibles - html += "#{invisible}" - html - updateLineNode: (id) -> oldLineState = @oldTileState.lines[id] newLineState = @newTileState.lines[id] diff --git a/src/lines-yardstick.coffee b/src/lines-yardstick.coffee new file mode 100644 index 00000000000..1820459c1a4 --- /dev/null +++ b/src/lines-yardstick.coffee @@ -0,0 +1,177 @@ +TokenIterator = require './token-iterator' +AcceptFilter = {acceptNode: -> NodeFilter.FILTER_ACCEPT} +rangeForMeasurement = document.createRange() +{Emitter} = require 'event-kit' +{Point} = require 'text-buffer' + +module.exports = +class LinesYardstick + constructor: (@editor) -> + @initialized = false + @emitter = new Emitter + @defaultStyleNode = document.createElement("style") + @skinnyStyleNode = document.createElement("style") + @iframe = document.createElement("iframe") + @iframe.style.display = "none" + @iframe.onload = @setupIframe + @lineNodesByLineId = {} + @tokenIterator = new TokenIterator + + getDomNode: -> @iframe + + setLineHtmlProvider: (@htmlProviderFn) -> + + setDefaultFont: (fontFamily, fontSize) -> + @defaultStyleNode.innerHTML = """ + * { + margin: 0; + padding: 0; + } + + body { + font-size: #{fontSize}; + font-family: #{fontFamily}; + white-space: pre; + } + """ + + onDidInitialize: (callback) -> + @emitter.on "did-initialize", callback + + onWillMeasureScreenRows: (callback) -> + @emitter.on "will-measure-screen-rows", callback + + canMeasure: -> + @initialized + + setupIframe: => + @initialized = true + @domNode = @iframe.contentDocument.body + @headNode = @iframe.contentDocument.head + + @headNode.appendChild(@defaultStyleNode) + @headNode.appendChild(@skinnyStyleNode) + + @emitter.emit "did-initialize" + + resetStyleSheets: (styleSheets) -> + skinnyCss = "" + + for style in styleSheets + for cssRule in style.sheet.cssRules when @hasFontStyling(cssRule) + skinnyCss += cssRule.cssText + + @skinnyStyleNode.innerHTML = skinnyCss + + hasFontStyling: (cssRule) -> + for styleProperty in cssRule.style + return true if styleProperty.indexOf("font") isnt -1 + + return false + + buildDomNodesForScreenRows: (screenRows) -> + return unless @canMeasure() + + @emitter.emit "will-measure-screen-rows", screenRows + visibleLines = {} + html = "" + newLinesIds = [] + screenRows.forEach (screenRow) => + line = @editor.tokenizedLineForScreenRow(screenRow) + return unless line? + visibleLines[line.id] = true + return if @lineNodesByLineId.hasOwnProperty(line.id) + + if lineHtml = @htmlProviderFn(screenRow, line) + html += @htmlProviderFn(screenRow, line) + newLinesIds.push(line.id) + + for lineId, lineNode of @lineNodesByLineId + continue if visibleLines.hasOwnProperty(lineId) + + lineNode.remove() + delete @lineNodesByLineId[lineId] + + @domNode.insertAdjacentHTML("beforeend", html) + index = @domNode.children.length - 1 + while lineId = newLinesIds.pop() + lineNode = @domNode.children[index--] + @lineNodesByLineId[lineId] = lineNode + + lineNodeForScreenRow: (screenRow) -> + line = @editor.tokenizedLineForScreenRow(screenRow) + lineNode = @lineNodesByLineId[line.id] + lineNode + + pixelPositionForScreenPosition: (screenPosition, clip = true) -> + screenPosition = Point.fromObject(screenPosition) + screenPosition = @editor.clipScreenPosition(screenPosition) if clip + + targetRow = screenPosition.row + targetColumn = screenPosition.column + + top = targetRow * @editor.getLineHeightInPixels() + left = @leftPixelPositionForScreenPosition(screenPosition) + + {top, left} + + leftPixelPositionForScreenPosition: ({row, column}) -> + lineNode = @lineNodeForScreenRow(row) + return 0 unless lineNode? + + tokenizedLine = @editor.tokenizedLineForScreenRow(row) + iterator = document.createNodeIterator(lineNode, NodeFilter.SHOW_TEXT, AcceptFilter) + charIndex = 0 + + @tokenIterator.reset(tokenizedLine) + while @tokenIterator.next() + text = @tokenIterator.getText() + + textIndex = 0 + while textIndex < text.length + if @tokenIterator.isPairedCharacter() + char = text + charLength = 2 + textIndex += 2 + else + char = text[textIndex] + charLength = 1 + textIndex++ + + continue if char is '\0' + + unless textNode? + textNode = iterator.nextNode() + textNodeLength = textNode.textContent.length + textNodeIndex = 0 + nextTextNodeIndex = textNodeLength + + while nextTextNodeIndex <= charIndex + textNode = iterator.nextNode() + textNodeLength = textNode.textContent.length + textNodeIndex = nextTextNodeIndex + nextTextNodeIndex = textNodeIndex + textNodeLength + + if charIndex is column + indexWithinToken = charIndex - textNodeIndex + return @leftPixelPositionForCharInTextNode(textNode, indexWithinToken) + + charIndex += charLength + + if textNode? + @leftPixelPositionForCharInTextNode(textNode, textNode.textContent.length) + else + 0 + + leftPixelPositionForCharInTextNode: (textNode, charIndex) -> + rangeForMeasurement.setEnd(textNode, textNode.textContent.length) + + if charIndex is 0 + rangeForMeasurement.setStart(textNode, 0) + rangeForMeasurement.getBoundingClientRect().left + else if charIndex is textNode.textContent.length + rangeForMeasurement.setStart(textNode, 0) + rangeForMeasurement.getBoundingClientRect().right + else + rangeForMeasurement.setStart(textNode, charIndex) + rangeForMeasurement.getBoundingClientRect().left diff --git a/src/text-editor-component.coffee b/src/text-editor-component.coffee index 81f92476f8d..f39a45fee9f 100644 --- a/src/text-editor-component.coffee +++ b/src/text-editor-component.coffee @@ -12,6 +12,8 @@ LinesComponent = require './lines-component' ScrollbarComponent = require './scrollbar-component' ScrollbarCornerComponent = require './scrollbar-corner-component' OverlayManager = require './overlay-manager' +LinesYardstick = require './lines-yardstick' +LineHtmlBuilder = require './line-html-builder' module.exports = class TextEditorComponent @@ -43,6 +45,13 @@ class TextEditorComponent @observeConfig() @setScrollSensitivity(atom.config.get('editor.scrollSensitivity')) + @lineHtmlBuilder = new LineHtmlBuilder(true) + @linesYardstick = new LinesYardstick(@editor) + @linesYardstick.onWillMeasureScreenRows => + @showIndentGuide = atom.config.get('editor.showIndentGuide') + @linesYardstick.setLineHtmlProvider (screenRow, line) => + @lineHtmlBuilder.buildLineHTML(@showIndentGuide, 0, line) + @presenter = new TextEditorPresenter model: @editor scrollTop: @editor.getScrollTop() @@ -51,7 +60,7 @@ class TextEditorComponent cursorBlinkPeriod: @cursorBlinkPeriod cursorBlinkResumeDelay: @cursorBlinkResumeDelay stoppedScrollingDelay: 200 - + linesYardstick: @linesYardstick @presenter.onDidUpdateState(@requestUpdate) @domNode = document.createElement('div') @@ -66,6 +75,8 @@ class TextEditorComponent @domNode.classList.add('editor-contents') @overlayManager = new OverlayManager(@presenter, @domNode) + @domNode.appendChild(@linesYardstick.getDomNode()) + @scrollViewNode = document.createElement('div') @scrollViewNode.classList.add('scroll-view') @domNode.appendChild(@scrollViewNode) @@ -102,6 +113,11 @@ class TextEditorComponent @updateSync() @checkForVisibilityChange() + @disposables.add @linesYardstick.onDidInitialize => + @linesYardstick.setDefaultFont(@fontFamily, @fontSize) + @linesYardstick.resetStyleSheets(@stylesElement.children) + @presenter.characterWidthsChanged() + destroy: -> @mounted = false @disposables.dispose() @@ -479,6 +495,7 @@ class TextEditorComponent @handleStylingChange() handleStylingChange: => + @linesYardstick.resetStyleSheets(@stylesElement.children) @sampleFontStyling() @sampleBackgroundColors() @remeasureCharacterWidths() @@ -617,6 +634,7 @@ class TextEditorComponent if @fontSize isnt oldFontSize or @fontFamily isnt oldFontFamily or @lineHeight isnt oldLineHeight @measureLineHeightAndDefaultCharWidth() + @linesYardstick.setDefaultFont(@fontFamily, @fontSize) if (@fontSize isnt oldFontSize or @fontFamily isnt oldFontFamily) and @performedInitialMeasurement @remeasureCharacterWidths() @@ -760,6 +778,21 @@ class TextEditorComponent left = clientX - linesClientRect.left + @presenter.scrollLeft {top, left} + prepareScreenRowsForMeasurement: (screenRows) -> + @linesYardstick.buildDomNodesForScreenRows(screenRows) + + pixelPositionForScreenPosition: (screenPosition, clip = true) -> + screenPosition = Point.fromObject(screenPosition) + screenPosition = @editor.clipScreenPosition(screenPosition) if clip + + targetRow = screenPosition.row + targetColumn = screenPosition.column + + top = targetRow * @editor.getLineHeightInPixels() + left = @linesYardstick.leftPixelPositionForScreenPosition(screenPosition) + + {top, left} + getModel: -> @editor diff --git a/src/text-editor-presenter.coffee b/src/text-editor-presenter.coffee index 1e103f2217d..149f101f0ea 100644 --- a/src/text-editor-presenter.coffee +++ b/src/text-editor-presenter.coffee @@ -16,7 +16,7 @@ class TextEditorPresenter {@model, @autoHeight, @explicitHeight, @contentFrameWidth, @scrollTop, @scrollLeft, @boundingClientRect, @windowWidth, @windowHeight, @gutterWidth} = params {horizontalScrollbarHeight, verticalScrollbarWidth} = params {@lineHeight, @baseCharacterWidth, @backgroundColor, @gutterBackgroundColor, @tileSize} = params - {@cursorBlinkPeriod, @cursorBlinkResumeDelay, @stoppedScrollingDelay, @focused} = params + {@cursorBlinkPeriod, @cursorBlinkResumeDelay, @stoppedScrollingDelay, @focused, @linesYardstick} = params @measuredHorizontalScrollbarHeight = horizontalScrollbarHeight @measuredVerticalScrollbarWidth = verticalScrollbarWidth @gutterWidth ?= 0 @@ -62,15 +62,56 @@ class TextEditorPresenter isBatching: -> @updating is false + getMeasurableScreenRows: -> + measurableRows = new Set + + for decoration in @model.getOverlayDecorations() + marker = decoration.getMarker() + continue unless marker.isValid() + + if decoration.getProperties().position is 'tail' + screenPosition = marker.getTailScreenPosition() + else + screenPosition = marker.getHeadScreenPosition() + + measurableRows.add(screenPosition.row) + + for markerId, decorations of @model.decorationsForScreenRowRange(@startRow, @endRow - 1) + hasHighlights = + decorations.some (decoration) -> decoration.isType('highlight') + continue unless hasHighlights + + range = @model.getMarker(markerId).getScreenRange() + range.start.row = Math.max(@startRow, range.start.row) + range.end.row = Math.min(@endRow, range.end.row) + measurableRows.add(row) for row in range.getRows() by 1 + + for cursor in @model.cursors + screenRow = cursor.getScreenRow() + continue unless cursor.isVisible() and @startRow <= screenRow < @endRow + measurableRows.add(screenRow) + + if lastCursorRange = @model.getLastCursor()?.getScreenRange() + measurableRows.add(lastCursorRange.start.row) + measurableRows.add(lastCursorRange.end.row) + + if longestScreenRow = @model.getLongestScreenRow() + measurableRows.add(longestScreenRow) + + measurableRows + # Public: Gets this presenter's state, updating it just in time before returning from this function. # Returns a state {Object}, useful for rendering to screen. getState: -> @updating = true - @updateContentDimensions() - @updateScrollbarDimensions() + @updateVerticalDimensions() @updateStartRow() @updateEndRow() + @linesYardstick.buildDomNodesForScreenRows(@getMeasurableScreenRows()) + + @updateHorizontalDimensions() + @updateScrollbarDimensions() @updateCommonGutterState() @updateFocusedState() if @shouldUpdateFocusedState @@ -112,7 +153,7 @@ class TextEditorPresenter observeModel: -> @disposables.add @model.onDidChange => - @updateContentDimensions() + @updateVerticalDimensions() @shouldUpdateHeightState = true @shouldUpdateVerticalScrollState = true @@ -231,10 +272,13 @@ class TextEditorPresenter @shouldUpdateLinesState = true @shouldUpdateLineNumbersState = true - @updateContentDimensions() - @updateScrollbarDimensions() + @updateVerticalDimensions() @updateStartRow() @updateEndRow() + @linesYardstick.buildDomNodesForScreenRows(@getMeasurableScreenRows()) + + @updateHorizontalDimensions() + @updateScrollbarDimensions() @updateFocusedState() @updateHeightState() @@ -387,28 +431,33 @@ class TextEditorPresenter lineState.top = (row - startRow) * @lineHeight lineState.decorationClasses = @lineDecorationClassesForRow(row) else - tileState.lines[line.id] = - screenRow: row - text: line.text - openScopes: line.openScopes - tags: line.tags - specialTokens: line.specialTokens - firstNonWhitespaceIndex: line.firstNonWhitespaceIndex - firstTrailingWhitespaceIndex: line.firstTrailingWhitespaceIndex - invisibles: line.invisibles - endOfLineInvisibles: line.endOfLineInvisibles - isOnlyWhitespace: line.isOnlyWhitespace() - indentLevel: line.indentLevel - tabLength: line.tabLength - fold: line.fold - top: (row - startRow) * @lineHeight - decorationClasses: @lineDecorationClassesForRow(row) + lineState = tileState.lines[line.id] = @buildLineState(row, line) + lineState.top = (row - startRow) * @lineHeight + row++ for id, line of tileState.lines delete tileState.lines[id] unless visibleLineIds.hasOwnProperty(id) return + buildLineState: (row, line) -> + { + screenRow: row + text: line.text + openScopes: line.openScopes + tags: line.tags + specialTokens: line.specialTokens + firstNonWhitespaceIndex: line.firstNonWhitespaceIndex + firstTrailingWhitespaceIndex: line.firstTrailingWhitespaceIndex + invisibles: line.invisibles + endOfLineInvisibles: line.endOfLineInvisibles + isOnlyWhitespace: line.isOnlyWhitespace() + indentLevel: line.indentLevel + tabLength: line.tabLength + fold: line.fold + decorationClasses: @lineDecorationClassesForRow(row) + } + updateCursorsState: -> @state.content.cursors = {} @updateCursorState(cursor) for cursor in @model.cursors # using property directly to avoid allocation @@ -647,11 +696,17 @@ class TextEditorPresenter @scrollHeight = scrollHeight @updateScrollTop() - updateContentDimensions: -> + updateVerticalDimensions: -> if @lineHeight? oldContentHeight = @contentHeight @contentHeight = @lineHeight * @model.getScreenLineCount() + if @contentHeight isnt oldContentHeight + @updateHeight() + @updateScrollbarDimensions() + @updateScrollHeight() + + updateHorizontalDimensions: -> if @baseCharacterWidth? oldContentWidth = @contentWidth clip = @model.tokenizedLineForScreenRow(@model.getLongestScreenRow())?.isSoftWrapped() @@ -659,15 +714,14 @@ class TextEditorPresenter @contentWidth += @scrollLeft @contentWidth += 1 unless @model.isSoftWrapped() # account for cursor width - if @contentHeight isnt oldContentHeight - @updateHeight() - @updateScrollbarDimensions() - @updateScrollHeight() - if @contentWidth isnt oldContentWidth @updateScrollbarDimensions() @updateScrollWidth() + updateContentDimensions: -> + @updateVerticalDimensions() + @updateHorizontalDimensions() + updateClientHeight: -> return unless @height? and @horizontalScrollbarHeight? @@ -1037,38 +1091,9 @@ class TextEditorPresenter hasPixelPositionRequirements: -> @lineHeight? and @baseCharacterWidth? - pixelPositionForScreenPosition: (screenPosition, clip=true) -> - screenPosition = Point.fromObject(screenPosition) - screenPosition = @model.clipScreenPosition(screenPosition) if clip - - targetRow = screenPosition.row - targetColumn = screenPosition.column - baseCharacterWidth = @baseCharacterWidth - - top = targetRow * @lineHeight - left = 0 - column = 0 - - iterator = @model.tokenizedLineForScreenRow(targetRow).getTokenIterator() - while iterator.next() - characterWidths = @getScopedCharacterWidths(iterator.getScopes()) - - valueIndex = 0 - text = iterator.getText() - while valueIndex < text.length - if iterator.isPairedCharacter() - char = text - charLength = 2 - valueIndex += 2 - else - char = text[valueIndex] - charLength = 1 - valueIndex++ - - break if column is targetColumn - - left += characterWidths[char] ? baseCharacterWidth unless char is '\0' - column += charLength + pixelPositionForScreenPosition: (screenPosition, clip) -> + {top, left} = + @linesYardstick.pixelPositionForScreenPosition(screenPosition, clip) top -= @scrollTop left -= @scrollLeft