diff --git a/spec/display-layer-spec.js b/spec/display-layer-spec.js index 07ff4c7ed..1be460971 100644 --- a/spec/display-layer-spec.js +++ b/spec/display-layer-spec.js @@ -2004,6 +2004,36 @@ describe('DisplayLayer', () => { }]) }) + it('invalidates only the screen rows that a highlighting change touches within a soft-wrapped buffer row', () => { + const buffer = new TextBuffer({ + text: 'aaaa bbbb cccc dddd eeee\nxyz' + }) + + const displayLayer = buffer.addDisplayLayer({softWrapColumn: 5}) + const languageMode = new TestLanguageMode([]) + buffer.setLanguageMode(languageMode) + expect(displayLayer.getText()).toBe('aaaa \nbbbb \ncccc \ndddd \neeee\nxyz') + + const idsBefore = displayLayer.getScreenLines().map((line) => line.id) + const allChanges = [] + displayLayer.onDidChange((changes) => allChanges.push(...changes)) + + // (0, 6) is on screen row 1; (0, 10) is the soft wrap at the start of + // screen row 2, so screen row 2 is included. + languageMode.emitHighlightingChangeEvent([[0, 6], [0, 10]]) + + expect(allChanges).toEqual([{ + oldRange: Range(Point(1, 0), Point(3, 0)), + newRange: Range(Point(1, 0), Point(3, 0)) + }]) + + const idsAfter = displayLayer.getScreenLines().map((line) => line.id) + expect(idsAfter[0]).toBe(idsBefore[0]) + expect(idsAfter[1]).not.toBe(idsBefore[1]) + expect(idsAfter[2]).not.toBe(idsBefore[2]) + expect(idsAfter.slice(3)).toEqual(idsBefore.slice(3)) + }) + it('gracefully handles the text decoration iterator reporting decoration boundaries beyond the end of a line', () => { const buffer = new TextBuffer({ text: 'abc\ndef' @@ -2478,10 +2508,12 @@ describe('DisplayLayer', () => { const randomizedTest = it('updates the displayed text correctly when the underlying buffer changes', () => { + // Set the SEED environment variable to re-run a single failing seed. + const fixedSeed = process.env.SEED ? Number(process.env.SEED) : null const now = Date.now() - for (let i = 0; i < 200; i++) { - let seed = now + i + for (let i = 0; i < (fixedSeed == null ? 200 : 1); i++) { + let seed = fixedSeed == null ? now + i : fixedSeed try { const random = new Random(seed) @@ -2563,6 +2595,14 @@ describe('DisplayLayer', () => { } verifyTokenConsistency(displayLayer) verifyText(random, displayLayer, freshDisplayLayer) + verifyScreenLinesForRandomRange(random, displayLayer, freshDisplayLayer) + verifySpatialIndexMatchesNewDisplayLayer(displayLayer, { + tabLength: 4, + invisibles, + showIndentGuides, + softWrapColumn: displayLayer.softWrapColumn, + foldsMarkerLayer: displayLayer.foldsMarkerLayer.copy() + }) verifyRightmostScreenPosition(freshDisplayLayer) verifyScreenLineIds(displayLayer, screenLinesById) verifyPositionTranslations(random, displayLayer) @@ -2578,8 +2618,159 @@ describe('DisplayLayer', () => { } } }) + + it('re-wraps earlier screen rows whose overflow depends on an edit', () => { + // The first screen row has no indentation, so it has room for more + // characters than the continuation screen rows below it. Its overflow + // check reads up to buffer column 10, which is on screen row 2. + const buffer = new TextBuffer({text: 'a ' + 'b'.repeat(20)}) + const displayLayer = buffer.addDisplayLayer({softWrapColumn: 10, softWrapHangingIndent: 4}) + expect(displayLayer.getText()).toBe('a \n bbbbbb\n bbbbbb\n bbbbbb\n bb') + expect(displayLayer.translateBufferPosition([0, 9]).row).toBe(2) + + // A space at buffer column 9 makes buffer column 10 a wrap boundary. + buffer.insert([0, 9], ' ') + expect(displayLayer.getText()).toBe('a bbbbbbb \n bbbbbb\n bbbbbb\n b') + expect(displayLayer.getText()).toBe(buffer.addDisplayLayer({softWrapColumn: 10, softWrapHangingIndent: 4}).getText()) + }) + + // `copy()` copies the spatial index, so the test above can't tell whether + // the index was updated correctly. This one compares against a display layer + // built from scratch after each edit. + const longBufferRowTest = + it('re-wraps long buffer rows correctly after edits within them', () => { + const fixedSeed = process.env.SEED ? Number(process.env.SEED) : null + const now = Date.now() + let editsWithinBufferRow = 0 + + for (let i = 0; i < (fixedSeed == null ? 100 : 1); i++) { + const seed = fixedSeed == null ? now + i : fixedSeed + + try { + const random = new Random(seed) + const buffer = new TextBuffer({text: buildLongBufferRows(random)}) + const params = { + tabLength: random.intBetween(2, 8), + softWrapColumn: random.intBetween(5, 80), + softWrapHangingIndent: random(2) ? 0 : random.intBetween(1, 6), + showIndentGuides: Boolean(random(2)), + invisibles: random(2) ? {space: '•'} : {}, + ratioForCharacter: (character) => character === '中' ? 2 : 1, + isWrapBoundary: (previousCharacter, character) => + ((previousCharacter === ' ' || previousCharacter === '\t' || + previousCharacter === '-' || previousCharacter === '/') && + character !== ' ' && character !== '\t') || + character === '中' + } + const displayLayer = buffer.addDisplayLayer(params) + const languageMode = random(2) ? new TestLanguageMode([], buffer, random) : null + if (languageMode) buffer.setLanguageMode(languageMode) + + const updateWithinBufferRow = displayLayer.updateSpatialIndexForEditWithinBufferRow + displayLayer.updateSpatialIndexForEditWithinBufferRow = function (change) { + const result = updateWithinBufferRow.call(this, change) + if (result) editsWithinBufferRow++ + return result + } + + displayLayer.getScreenLines() + + for (let j = 0; j < 20; j++) { + const bufferRow = random(buffer.getLineCount()) + const lineLength = buffer.lineLengthForRow(bufferRow) + // Edits near the start of a buffer row exercise the search for the + // first affected screen row, which differs for the first screen row. + const startColumn = random(2) + ? random(lineLength + 1) + : random(Math.min(lineLength, 4 * params.softWrapColumn) + 1) + const endColumn = Math.min(lineLength, startColumn + (random(3) ? 0 : random.intBetween(1, 6))) + const text = random(4) ? buildLongBufferRowText(random, random.intBetween(1, 6)) : '' + const range = Range(Point(bufferRow, startColumn), Point(bufferRow, endColumn)) + log('edit ' + range + ' ' + JSON.stringify(text)) + + verifyChangeEvent(displayLayer, () => buffer.setTextInRange(range, text)) + + // Sometimes leave part of the display layer's cache cold. + if (random(2)) { + const startRow = random(displayLayer.getScreenLineCount()) + displayLayer.getScreenLines(startRow, startRow + random.intBetween(1, 10)) + } else { + displayLayer.getScreenLines() + } + + // Change highlighting (but not text) over a short range, so that only + // some screen rows of a long buffer row are invalidated. + if (languageMode && random(2)) { + const highlightRow = random(buffer.getLineCount()) + const highlightRowLength = buffer.lineLengthForRow(highlightRow) + const highlightStart = random(highlightRowLength + 1) + const highlightEnd = Math.min(highlightRowLength, highlightStart + random(2 * params.softWrapColumn)) + const highlightRange = Range(Point(highlightRow, highlightStart), Point(highlightRow, highlightEnd)) + log('highlight ' + highlightRange) + const markerId = languageMode.getNextMarkerId() + languageMode.classNamesByScopeId.set(markerId, 'h') + languageMode.markerIndex.insert(markerId, highlightRange.start, highlightRange.end) + languageMode.emitHighlightingChangeEvent(highlightRange) + } + + const freshDisplayLayer = buffer.addDisplayLayer(params) + const screenLineCount = freshDisplayLayer.getScreenLineCount() + expect(displayLayer.getScreenLineCount()).toBe(screenLineCount) + expect(displayLayer.spatialIndex.getChanges()).toEqual(freshDisplayLayer.spatialIndex.getChanges()) + expect(displayLayer.screenLineLengths).toEqual(freshDisplayLayer.screenLineLengths) + expect(displayLayer.tabCounts).toEqual(freshDisplayLayer.tabCounts) + verifyRightmostScreenPosition(displayLayer) + expect(displayLayer.getText()).toBe(freshDisplayLayer.getText()) + + const startRow = random(screenLineCount) + const endRow = random.intBetween(startRow, screenLineCount) + expect({startRow, endRow, tokens: getTokens(displayLayer, startRow, endRow)}) + .toEqual({startRow, endRow, tokens: getTokens(freshDisplayLayer, startRow, endRow)}) + freshDisplayLayer.destroy() + + if (longBufferRowTest.result.failedExpectations.length > 0) { + console.log(`Failing Seed: ${seed}`) + return + } + } + } catch (error) { + console.log(`Failing Seed: ${seed}`) + throw error + } + } + + expect(editsWithinBufferRow).toBeGreaterThan(0) + }) }) +const LONG_BUFFER_ROW_WORDS = ['alpha', 'be', 'c', 'delta-epsilon', 'a/b/c', '中中', 'longidentifiername', 'x', 'b'.repeat(40)] + +function buildLongBufferRowText (random, length) { + let text = '' + while (text.length < length) { + const k = random(10) + if (k < 5) { + text += LONG_BUFFER_ROW_WORDS[random(LONG_BUFFER_ROW_WORDS.length)] + } else if (k < 8) { + text += ' ' + } else if (k < 9) { + text += '\t' + } else { + text += '-' + } + } + return text.slice(0, length) +} + +function buildLongBufferRows (random) { + const lines = [] + for (let i = 0, n = random.intBetween(1, 3); i < n; i++) { + const indent = random(2) ? '' : (random(2) ? ' ' : '\t').repeat(random.intBetween(1, 3)) + lines.push(indent + buildLongBufferRowText(random, random.intBetween(100, 800))) + } + return lines.join('\n') +} + function performRandomChange (random, displayLayer) { const text = buildRandomLines(random, 4) const range = getRandomBufferRange(random, displayLayer.buffer) @@ -2672,6 +2863,39 @@ function verifyText (random, displayLayer, freshDisplayLayer) { expect(JSON.stringify(text)).toBe(JSON.stringify(expectedText), `Text for rows ${startRow} - ${endRow}`) } +// `copy()` copies the spatial index, so compare it to one built from scratch. +function verifySpatialIndexMatchesNewDisplayLayer (displayLayer, params) { + const newDisplayLayer = displayLayer.buffer.addDisplayLayer(params) + newDisplayLayer.getScreenLines() + displayLayer.getScreenLines() + expect({spatialIndex: displayLayer.spatialIndex.getChanges()}) + .toEqual({spatialIndex: newDisplayLayer.spatialIndex.getChanges()}) + expect({screenLineLengths: displayLayer.screenLineLengths, tabCounts: displayLayer.tabCounts}) + .toEqual({screenLineLengths: newDisplayLayer.screenLineLengths, tabCounts: newDisplayLayer.tabCounts}) + newDisplayLayer.destroy() +} + +// Build screen lines for a random range of screen rows, both on the existing +// display layer (with whatever is in its cache) and on a copy with a cold +// cache, and compare their tokens to those of a display layer that built every +// screen line from the top. A range may start partway through a soft-wrapped +// buffer row. +function verifyScreenLinesForRandomRange (random, displayLayer, freshDisplayLayer) { + const screenLineCount = freshDisplayLayer.getScreenLineCount() + const startRow = random(screenLineCount) + const endRow = random.intBetween(startRow, screenLineCount) + const expectedTokens = getTokens(freshDisplayLayer, startRow, endRow) + + // Jasmine 2 has no custom failure messages, so label the compared values. + const coldDisplayLayer = displayLayer.copy() + expect({cache: 'cold', startRow, endRow, tokens: getTokens(coldDisplayLayer, startRow, endRow)}) + .toEqual({cache: 'cold', startRow, endRow, tokens: expectedTokens}) + coldDisplayLayer.destroy() + + expect({cache: 'warm', startRow, endRow, tokens: getTokens(displayLayer, startRow, endRow)}) + .toEqual({cache: 'warm', startRow, endRow, tokens: expectedTokens}) +} + function verifyLineLengths (displayLayer) { const rowCount = getComputedScreenLineCount(displayLayer) const screenLines = displayLayer.getScreenLines(0, rowCount) diff --git a/spec/helpers/test-language-mode.js b/spec/helpers/test-language-mode.js index 2eb3e01e4..834f5d1a2 100644 --- a/spec/helpers/test-language-mode.js +++ b/spec/helpers/test-language-mode.js @@ -112,11 +112,11 @@ class TestHighlightIterator { this.layer = layer } - seek (position, endBufferRow) { + seek (position, endBufferPosition) { const {containingStart, boundaries} = this.layer.markerIndex.findBoundariesAfter(position, Number.MAX_SAFE_INTEGER) this.boundaries = boundaries this.boundaryIndex = 0 - this.endBufferRow = endBufferRow + this.endBufferPosition = Point.fromObject(endBufferPosition) return containingStart } @@ -133,7 +133,7 @@ class TestHighlightIterator { const result = [] const boundary = this.boundaries[this.boundaryIndex] if (boundary) { - expect(boundary.position.row).not.toBeGreaterThan(this.endBufferRow) + expect(Point.fromObject(boundary.position).isLessThanOrEqual(this.endBufferPosition)).toBe(true) boundary.ending.forEach((markerId) => { if (!boundary.starting.has(markerId)) { result.push(markerId) @@ -147,7 +147,7 @@ class TestHighlightIterator { const result = [] const boundary = this.boundaries[this.boundaryIndex] if (boundary) { - expect(boundary.position.row).not.toBeGreaterThan(this.endBufferRow) + expect(Point.fromObject(boundary.position).isLessThanOrEqual(this.endBufferPosition)).toBe(true) boundary.starting.forEach((markerId) => { if (!boundary.ending.has(markerId)) { result.push(markerId) diff --git a/src/buffer-row-reader.js b/src/buffer-row-reader.js new file mode 100644 index 000000000..566601d89 --- /dev/null +++ b/src/buffer-row-reader.js @@ -0,0 +1,42 @@ +const Point = require('./point') +const Range = require('./range') + +const DEFAULT_CHUNK_SIZE = 4096 + +// Reads the characters of one buffer row a chunk at a time, so that working on +// part of a long buffer row doesn't require fetching all of it, and reading a +// buffer row character by character doesn't cost a buffer call per character. +class BufferRowReader { + constructor (buffer, bufferRow, lineLength, chunkSize = DEFAULT_CHUNK_SIZE) { + this.buffer = buffer + this.bufferRow = bufferRow + this.lineLength = lineLength + this.chunkSize = chunkSize + this.chunkStart = 0 + this.chunk = '' + } + + // Returns undefined for columns outside the buffer row. + characterAt (column) { + if (column < 0 || column >= this.lineLength) return undefined + const index = column - this.chunkStart + if (index >= 0 && index < this.chunk.length) return this.chunk[index] + + if (index === this.chunk.length) { + // Reading forward past the end of the chunk: continue from here. + this.chunkStart = column + } else { + // Mostly we read forward, but the search for the first affected screen + // row reads backward, so keep some text before the requested column. + this.chunkStart = Math.max(0, column - (this.chunkSize >> 2)) + } + const chunkEnd = Math.min(this.lineLength, this.chunkStart + this.chunkSize) + this.chunk = this.buffer.getTextInRange(Range( + Point(this.bufferRow, this.chunkStart), + Point(this.bufferRow, chunkEnd) + )) + return this.chunk[column - this.chunkStart] + } +} + +module.exports = BufferRowReader diff --git a/src/display-layer.js b/src/display-layer.js index 705002185..b40cb3fc5 100644 --- a/src/display-layer.js +++ b/src/display-layer.js @@ -6,6 +6,7 @@ const DisplayMarkerLayer = require('./display-marker-layer') const {traverse, traversal, compare, max, isEqual} = require('./point-helpers') const isCharacterPair = require('./is-character-pair') const ScreenLineBuilder = require('./screen-line-builder') +const BufferRowReader = require('./buffer-row-reader') const {spliceArray} = require('./helpers') const {MAX_BUILT_IN_SCOPE_ID} = require('./constants') @@ -147,12 +148,21 @@ class DisplayLayer { this.cachedScreenLines.length = 0 if (this.languageModeDisposable) this.languageModeDisposable.dispose() this.languageModeDisposable = this.buffer.languageMode.onDidChangeHighlighting((bufferRange) => { - bufferRange = Range.fromObject(bufferRange) + bufferRange = this.buffer.clipRange(bufferRange) this.populateSpatialIndexIfNeeded(bufferRange.end.row + 1, Infinity) - const startBufferRow = this.findBoundaryPrecedingBufferRow(bufferRange.start.row) - const endBufferRow = this.findBoundaryFollowingBufferRow(bufferRange.end.row + 1) - const startRow = this.translateBufferPositionWithSpatialIndex(Point(startBufferRow, 0), 'backward').row - const endRow = this.translateBufferPositionWithSpatialIndex(Point(endBufferRow, 0), 'backward').row + // Invalidate only the screen rows that contain part of the range, rather + // than every screen row of the buffer rows it touches. This works around + // folds and soft wraps without special handling: + // + // * A position inside folded text translates to the screen row that + // holds the fold. + // * A position at a soft wrap translates to the start of the following + // screen row, which is where the builder emits boundaries found there. + // + // We treat the end of the range as inclusive, since a boundary exactly at + // `bufferRange.end` may have changed. + const startRow = this.translateBufferPositionWithSpatialIndex(bufferRange.start, 'backward').row + const endRow = this.translateBufferPositionWithSpatialIndex(bufferRange.end, 'forward').row + 1 const extent = Point(endRow - startRow, 0) spliceArray(this.cachedScreenLines, startRow, extent.row, new Array(extent.row)) this.didChange({ @@ -298,7 +308,7 @@ class DisplayLayer { let screenPosition = this.translateBufferPositionWithSpatialIndex(bufferPosition, clipDirection) const tabCount = this.tabCounts[screenPosition.row] if (tabCount > 0) { - screenPosition = this.expandHardTabs(screenPosition, bufferPosition, tabCount) + screenPosition = this.expandHardTabs(screenPosition, tabCount) } return Point.fromObject(screenPosition) @@ -434,14 +444,14 @@ class DisplayLayer { return screenPosition } - expandHardTabs (targetScreenPosition, targetBufferPosition, tabCount) { + expandHardTabs (targetScreenPosition, tabCount) { const screenRowStart = Point(targetScreenPosition.row, 0) const hunks = this.spatialIndex.getChangesInNewRange(screenRowStart, targetScreenPosition) let hunkIndex = 0 let unexpandedScreenColumn = 0 let expandedScreenColumn = 0 let {row: bufferRow, column: bufferColumn} = this.translateScreenPositionWithSpatialIndex(screenRowStart) - let bufferLine = this.buffer.lineForRow(bufferRow) + let reader = new BufferRowReader(this.buffer, bufferRow, this.buffer.lineLengthForRow(bufferRow)) while (tabCount > 0) { if (unexpandedScreenColumn === targetScreenPosition.column) { @@ -456,7 +466,7 @@ class DisplayLayer { expandedScreenColumn = unexpandedScreenColumn } else { ({row: bufferRow, column: bufferColumn} = nextHunk.oldEnd) - bufferLine = this.buffer.lineForRow(bufferRow) + reader = new BufferRowReader(this.buffer, bufferRow, this.buffer.lineLengthForRow(bufferRow)) unexpandedScreenColumn++ expandedScreenColumn++ } @@ -465,7 +475,7 @@ class DisplayLayer { continue } - if (bufferLine[bufferColumn] === '\t') { + if (reader.characterAt(bufferColumn) === '\t') { expandedScreenColumn += (this.tabLength - (expandedScreenColumn % this.tabLength)) tabCount-- } else { @@ -492,7 +502,7 @@ class DisplayLayer { let unexpandedScreenColumn = 0 let expandedScreenColumn = 0 let {row: bufferRow, column: bufferColumn} = this.translateScreenPositionWithSpatialIndex(screenRowStart) - let bufferLine = this.buffer.lineForRow(bufferRow) + let reader = new BufferRowReader(this.buffer, bufferRow, this.buffer.lineLengthForRow(bufferRow)) while (tabCount > 0) { if (expandedScreenColumn === targetScreenPosition.column) { @@ -507,7 +517,7 @@ class DisplayLayer { expandedScreenColumn = unexpandedScreenColumn } else { ({row: bufferRow, column: bufferColumn} = nextHunk.oldEnd) - bufferLine = this.buffer.lineForRow(bufferRow) + reader = new BufferRowReader(this.buffer, bufferRow, this.buffer.lineLengthForRow(bufferRow)) unexpandedScreenColumn++ expandedScreenColumn++ } @@ -515,7 +525,7 @@ class DisplayLayer { continue } - if (bufferLine[bufferColumn] === '\t') { + if (reader.characterAt(bufferColumn) === '\t') { const nextTabStopColumn = expandedScreenColumn + this.tabLength - (expandedScreenColumn % this.tabLength) if (nextTabStopColumn > targetScreenPosition.column) { if (clipDirection === 'backward') { @@ -785,7 +795,14 @@ class DisplayLayer { this.populateSpatialIndexIfNeeded(endRow + 1, Infinity) } - bufferDidChange ({oldRange, newRange}) { + bufferDidChange (change) { + const changeWithinBufferRow = this.updateSpatialIndexForEditWithinBufferRow(change) + if (changeWithinBufferRow) { + this.didChange(changeWithinBufferRow) + return + } + + const {oldRange, newRange} = change let startRow = oldRange.start.row let oldEndRow = oldRange.end.row let newEndRow = newRange.end.row @@ -871,6 +888,13 @@ class DisplayLayer { let lastWrapBoundaryExpandedScreenColumn = 0 let lastWrapBoundaryScreenLineWidth = 0 let firstNonWhitespaceScreenColumn = -1 + // Whether the current screen row contains anything yet. A screen row + // can't wrap before its first character, and the position of its first + // character is not a usable wrap boundary. + let consumedCharacterOnScreenRow = false + // The unexpanded screen column of the last fold on the current screen + // row, or -1. + let foldUnexpandedScreenColumn = -1 while (bufferColumn <= bufferLineLength) { const foldEnd = folds[bufferRow] && folds[bufferRow][bufferColumn] @@ -885,7 +909,8 @@ class DisplayLayer { firstNonWhitespaceScreenColumn = expandedScreenColumn } } else { - if (previousCharacter && + if (consumedCharacterOnScreenRow && + previousCharacter && character && this.isWrapBoundary(previousCharacter, character)) { lastWrapBoundaryUnexpandedScreenColumn = unexpandedScreenColumn @@ -906,6 +931,7 @@ class DisplayLayer { } const insertSoftLineBreak = + consumedCharacterOnScreenRow && screenLineWidth > 0 && characterWidth > 0 && screenLineWidth + characterWidth > this.softWrapColumn && previousCharacter && character && @@ -935,9 +961,45 @@ class DisplayLayer { } screenRow++ + if (foldUnexpandedScreenColumn < unexpandedWrapColumn) { + // Rewind to the wrap boundary and scan the characters following + // it again as the start of the new screen row. Their widths (and + // which of them are wrap boundaries) are then determined by the + // new screen row alone, so the layout of every continuation screen + // row depends only on its starting buffer column, its indentation, + // and the text that follows. This is what allows an edit to stop + // re-wrapping a buffer row once the new soft wraps line up with + // the old ones. + let tabCountPrecedingWrap = 0 + while ( + tabCountPrecedingWrap < currentScreenLineTabColumns.length && + currentScreenLineTabColumns[tabCountPrecedingWrap] < unexpandedWrapColumn + ) { + tabCountPrecedingWrap++ + } + insertedTabCounts.push(tabCountPrecedingWrap) + currentScreenLineTabColumns.length = 0 + + bufferColumn -= (unexpandedScreenColumn - unexpandedWrapColumn) + unexpandedScreenColumn = indentLength + expandedScreenColumn = indentLength + screenLineWidth = indentLength * this.ratioForCharacter(' ') + lastWrapBoundaryUnexpandedScreenColumn = 0 + lastWrapBoundaryExpandedScreenColumn = 0 + lastWrapBoundaryScreenLineWidth = 0 + consumedCharacterOnScreenRow = false + foldUnexpandedScreenColumn = -1 + continue + } + + // A fold lies between the wrap boundary and the current position, so + // we can't rewind without un-splicing it. Instead, carry the text + // following the wrap boundary over to the new screen row. + // // To determine the expanded screen column following the wrap, we need // to re-expand each tab following the wrap boundary, because tabs may // take on different lengths due to starting at different screen columns. + foldUnexpandedScreenColumn = foldUnexpandedScreenColumn - unexpandedWrapColumn + indentLength let unexpandedScreenColumnAfterLastTab = indentLength let expandedScreenColumnAfterLastTab = indentLength let tabCountPrecedingWrap = 0 @@ -973,6 +1035,7 @@ class DisplayLayer { traversal(foldEnd, {row: bufferRow, column: bufferColumn}), {row: 0, column: 1} ) + foldUnexpandedScreenColumn = unexpandedScreenColumn unexpandedScreenColumn++ expandedScreenColumn++ screenLineWidth += characterWidth @@ -995,6 +1058,7 @@ class DisplayLayer { unexpandedScreenColumn++ bufferColumn++ } + consumedCharacterOnScreenRow = true } expandedScreenColumn-- @@ -1034,11 +1098,34 @@ class DisplayLayer { insertedTabCounts ) + this.updateRightmostScreenPosition( + startScreenRow, + oldScreenRowCount, + insertedScreenLineLengths.length, + rightmostInsertedScreenPosition + ) + + spliceArray( + this.cachedScreenLines, + startScreenRow, + oldScreenRowCount, + new Array(insertedScreenLineLengths.length) + ) + + return { + start: Point(startScreenRow, 0), + oldExtent: Point(oldScreenRowCount, 0), + newExtent: Point(insertedScreenLineLengths.length, 0) + } + } + + // Call after splicing `screenLineLengths`. + updateRightmostScreenPosition (startScreenRow, oldScreenRowCount, newScreenRowCount, rightmostInsertedScreenPosition) { const lastRemovedScreenRow = startScreenRow + oldScreenRowCount if (rightmostInsertedScreenPosition.column > this.rightmostScreenPosition.column) { this.rightmostScreenPosition = rightmostInsertedScreenPosition } else if (lastRemovedScreenRow < this.rightmostScreenPosition.row) { - this.rightmostScreenPosition.row += insertedScreenLineLengths.length - oldScreenRowCount + this.rightmostScreenPosition.row += newScreenRowCount - oldScreenRowCount } else if (startScreenRow <= this.rightmostScreenPosition.row) { this.rightmostScreenPosition = Point(0, 0) for (let row = 0, rowCount = this.screenLineLengths.length; row < rowCount; row++) { @@ -1048,18 +1135,360 @@ class DisplayLayer { } } } + } - spliceArray( - this.cachedScreenLines, + // Handles an edit that stays within one soft-wrapped buffer row without + // re-wrapping the whole buffer row. Returns the screen-row change to report, + // or `null` if the edit doesn't qualify, in which case the caller should use + // `updateSpatialIndex`. + // + // The re-wrap starts at the first screen row of the buffer row that the edit + // can affect. That can be above the screen row containing the edit, since an + // earlier screen row decides where to wrap by reading up to the character + // that overflows it, which can be on a later screen row. It stops at the + // first new soft wrap after the edit that lands where an old soft wrap was + // (shifted by the length of the edit) with the same indentation. Since a + // continuation screen row's layout depends only on its starting buffer + // column, its indentation, and the text that follows, every soft wrap after + // that point is unchanged, and the spatial index only needs to shift them. + // + // This reduces the work done on the spatial index and the screen line + // lengths and tab counts. It still invalidates the cached screen lines for + // the entire buffer row and reports the entire buffer row as changed, + // because the language mode may have re-highlighted any part of it without + // telling us. + updateSpatialIndexForEditWithinBufferRow (change) { + // Skip this altogether unless we're soft-wrapping. + if (!Number.isFinite(this.softWrapColumn)) return null + + const {oldRange, newRange} = change + const bufferRow = oldRange.start.row + // Scenarios that don't qualify for this fast path: + // An edit that spans more than just one buffer row. + if (oldRange.end.row !== bufferRow || newRange.end.row !== bufferRow) return null + // An edit that includes any newlines (thereby creating a new buffer row). + // We wait until now to read this property: on a `ChangeEvent` (as when + // reloading from disk), `oldText` is computed on demand by rebuilding the + // old buffer text. + if (/[\r\n]/.test(change.oldText) || /[\r\n]/.test(change.newText)) return null + + // Indent guides on adjacent blank lines depend on this buffer row. + if (this.showIndentGuides) { + if (bufferRow > 0 && this.buffer.lineLengthForRow(bufferRow - 1) === 0) { + return null + } + if ( + bufferRow < this.buffer.getLastRow() && + this.buffer.lineLengthForRow(bufferRow + 1) === 0 + ) { + return null + } + } + + // If this buffer line is (or was) hidden by a fold, in whole or in part, + // we can't use this optimization. + if (this.foldsMarkerLayer.findMarkers({intersectsRow: bufferRow}).length > 0) { + return null + } + if (this.findBoundaryPrecedingBufferRow(bufferRow) !== bufferRow) { + return null + } + if (this.findBoundaryFollowingBufferRow(bufferRow + 1) !== bufferRow + 1) { + return null + } + + const editStartColumn = oldRange.start.column + const oldEditEndColumn = oldRange.end.column + const newEditEndColumn = newRange.end.column + const delta = newEditEndColumn - oldEditEndColumn + const lineLength = this.buffer.lineLengthForRow(bufferRow) + const oldLineLength = lineLength - delta + const reader = new BufferRowReader( + this.buffer, bufferRow, lineLength, Math.max(4096, 4 * this.softWrapColumn) + ) + + // An edit within the leading whitespace changes the indentation of every + // continuation screen row, so nothing would converge. + let firstNonWhitespaceBufferColumn = 0 + let firstNonWhitespaceScreenColumn = 0 + while (firstNonWhitespaceBufferColumn < editStartColumn) { + const character = reader.characterAt(firstNonWhitespaceBufferColumn) + if (character === '\t') { + // Hard tabs insert however much space is needed to bring us to the + // next tab stop. + firstNonWhitespaceScreenColumn += this.tabLength - (firstNonWhitespaceScreenColumn % this.tabLength) + } else if (character === ' ') { + firstNonWhitespaceScreenColumn++ + } else { + break + } + firstNonWhitespaceBufferColumn++ + } + if (firstNonWhitespaceBufferColumn >= editStartColumn) return null + + // Look up soft wraps in the spatial index, which still describes the old + // buffer text. Returns the last soft-wrap hunk of this buffer row at or + // before the given buffer column; `null` if there is none; or `undefined` + // if we find something we can't handle. + const softWrapAtOrBefore = (column) => { + if (column < 0) return null + const hunk = this.spatialIndex.changeForOldPosition(Point(bufferRow, column)) + if (!hunk || hunk.oldStart.row !== bufferRow) return null + return this.isSoftWrapHunk(hunk) ? hunk : undefined + } + + // A continuation screen row's state includes the first non-whitespace + // screen column, which we computed above on the assumption that it's on the + // first screen row. If it isn't, re-wrap from the start of the buffer row. + const firstWrapBeforeText = softWrapAtOrBefore(firstNonWhitespaceBufferColumn) + if (firstWrapBeforeText === undefined) return null + const canStartOnContinuationRow = firstWrapBeforeText === null + + const firstScreenRow = this.translateBufferPositionWithSpatialIndex(Point(bufferRow, 0)).row + + // Find the first screen row that might change. Start with the screen row + // containing the edit, then move up as long as the screen row above it + // might overflow at or after the edit. (A screen row reads past its own + // end, up to the character that overflows it.) + // + // Unintuitively, edits on screen row N can affect wrapping behavior not + // just on screen rows >N, and not just on screen row N-1, but on some + // screen rows above or below row N as long as they're part of the same + // buffer row. + // + // We can stop at the first screen row above that overflows before the edit + // only if no screen row above _that_ one can read further. This holds for + // continuation screen rows without tabs: they all have the same room, and + // a character's width doesn't depend on where it lands, so a screen row + // that starts later can't overflow earlier. (This means we can rule out + // some earlier screen rows from being affected when screen rows between + // them and the edit contain no hard tab characters.) + // + // It doesn't hold for the first screen row, which has no indentation and + // so has at least as much room; so we check that one separately. It also + // doesn't hold for tabs, whose width depends on the tab phase; if there + // are any, we re-wrap from the first screen row. + let startHunk = softWrapAtOrBefore(editStartColumn - 1) + if (startHunk === undefined) return null + if (startHunk) { + for (let screenRow = firstScreenRow + 1; screenRow <= startHunk.newEnd.row; screenRow++) { + if (this.tabCounts[screenRow] > 0) { + startHunk = null + break + } + } + } + while (startHunk) { + if (!canStartOnContinuationRow) { + startHunk = null + break + } + const previousHunk = softWrapAtOrBefore(startHunk.oldStart.column - 1) + if (previousHunk === undefined) return null + const result = this.scanSoftWrappedScreenRow( + reader, + previousHunk ? previousHunk.oldStart.column : 0, + previousHunk ? previousHunk.newEnd.column : 0, + previousHunk ? firstNonWhitespaceScreenColumn : -1, + editStartColumn + ) + if (result.wrapBufferColumn != null) { + // The screen row above wraps before reaching the edit, so it's + // unaffected. Its wrap should match the spatial index; if not, give up + // on the shortcut rather than build on something inconsistent. + if (result.wrapBufferColumn !== startHunk.oldStart.column) return null + + // Unless that was the first screen row, check it too. + if (previousHunk) { + const firstScreenRowResult = this.scanSoftWrappedScreenRow(reader, 0, 0, -1, editStartColumn) + if (firstScreenRowResult.wrapBufferColumn == null) startHunk = null + } + break + } + startHunk = previousHunk + } + + const oldLastScreenRow = this.translateBufferPositionWithSpatialIndex(Point(bufferRow, oldLineLength)).row + const startScreenRow = startHunk ? startHunk.newEnd.row : firstScreenRow + const startBufferColumn = startHunk ? startHunk.oldStart.column : 0 + + // Re-wrap until we converge with the old soft wraps or reach the end of the + // buffer row. + const insertedSoftWraps = [] + const insertedScreenLineLengths = [] + const insertedTabCounts = [] + let convergenceHunk = null + let bufferColumn = startBufferColumn + let indentLength = startHunk ? startHunk.newEnd.column : 0 + let firstNonWhitespace = startHunk ? firstNonWhitespaceScreenColumn : -1 + while (true) { + const result = this.scanSoftWrappedScreenRow(reader, bufferColumn, indentLength, firstNonWhitespace, Infinity) + insertedScreenLineLengths.push(result.screenLineLength) + insertedTabCounts.push(result.tabCount) + if (result.wrapBufferColumn == null) break + + insertedSoftWraps.push(result) + bufferColumn = result.wrapBufferColumn + indentLength = result.indentLength + firstNonWhitespace = result.firstNonWhitespaceScreenColumn + + if (bufferColumn >= newEditEndColumn) { + const oldBufferColumn = bufferColumn - delta + const oldHunk = softWrapAtOrBefore(oldBufferColumn) + if (oldHunk === undefined) return null + if (oldHunk && oldHunk.oldStart.column === oldBufferColumn && oldHunk.newEnd.column === indentLength) { + convergenceHunk = oldHunk + break + } + } + } + + // Replace the old soft wraps between the start of re-wrapping and the point + // of convergence (or the end of the buffer row). This also shifts the soft + // wraps after the point of convergence by the length of the edit. Soft-wrap + // hunks at either end of the old range are kept, so the soft wrap at the + // point of convergence is already in place. + const oldEndBufferColumn = convergenceHunk ? convergenceHunk.oldStart.column : oldLineLength + const oldEndScreenRow = convergenceHunk ? convergenceHunk.newEnd.row : oldLastScreenRow + 1 + this.spatialIndex.spliceOld( + Point(bufferRow, startBufferColumn), + Point(0, oldEndBufferColumn - startBufferColumn), + Point(0, oldEndBufferColumn - startBufferColumn + delta) + ) + const softWrapsToInsertCount = insertedSoftWraps.length - (convergenceHunk ? 1 : 0) + for (let i = 0; i < softWrapsToInsertCount; i++) { + const {wrapUnexpandedScreenColumn, indentLength} = insertedSoftWraps[i] + this.spatialIndex.splice( + Point(startScreenRow + i, wrapUnexpandedScreenColumn), + Point.ZERO, + Point(1, indentLength) + ) + } + + const oldScreenRowCount = oldEndScreenRow - startScreenRow + spliceArray(this.screenLineLengths, startScreenRow, oldScreenRowCount, insertedScreenLineLengths) + spliceArray(this.tabCounts, startScreenRow, oldScreenRowCount, insertedTabCounts) + + const rightmostInsertedScreenPosition = Point(0, -1) + for (let i = 0; i < insertedScreenLineLengths.length; i++) { + if (insertedScreenLineLengths[i] > rightmostInsertedScreenPosition.column) { + rightmostInsertedScreenPosition.row = startScreenRow + i + rightmostInsertedScreenPosition.column = insertedScreenLineLengths[i] + } + } + this.updateRightmostScreenPosition( startScreenRow, oldScreenRowCount, - new Array(insertedScreenLineLengths.length) + insertedScreenLineLengths.length, + rightmostInsertedScreenPosition + ) + + const oldBufferRowScreenRowCount = oldLastScreenRow + 1 - firstScreenRow + const newBufferRowScreenRowCount = oldBufferRowScreenRowCount + insertedScreenLineLengths.length - oldScreenRowCount + spliceArray( + this.cachedScreenLines, + firstScreenRow, + oldBufferRowScreenRowCount, + new Array(newBufferRowScreenRowCount) ) return { - start: Point(startScreenRow, 0), - oldExtent: Point(oldScreenRowCount, 0), - newExtent: Point(insertedScreenLineLengths.length, 0) + start: Point(firstScreenRow, 0), + oldExtent: Point(oldBufferRowScreenRowCount, 0), + newExtent: Point(newBufferRowScreenRowCount, 0) + } + } + + // Lay out one screen row of a buffer row without folds, starting at the given + // buffer column. This must make the same decisions as the main loop of + // `updateSpatialIndex`. Pass a negative `firstNonWhitespaceScreenColumn` for + // the first screen row of a buffer row. + // + // If the screen row wraps, returns the buffer column of the wrap + // (`wrapBufferColumn`), its unexpanded screen column, and the indentation and + // first non-whitespace screen column to start the next screen row with. + // Gives up and returns an empty object if it would have to read the + // character at `limitBufferColumn` to decide. + scanSoftWrappedScreenRow (reader, startBufferColumn, indentLength, firstNonWhitespaceScreenColumn, limitBufferColumn) { + const lineLength = reader.lineLength + let bufferColumn = startBufferColumn + let unexpandedScreenColumn = indentLength + let expandedScreenColumn = indentLength + let screenLineWidth = indentLength * this.ratioForCharacter(' ') + let lastWrapBoundaryUnexpandedScreenColumn = 0 + let lastWrapBoundaryExpandedScreenColumn = 0 + let tabCount = 0 + let tabCountBeforeLastWrapBoundary = 0 + let consumedCharacterOnScreenRow = false + + while (bufferColumn <= lineLength) { + if (bufferColumn >= limitBufferColumn) return {} + const previousCharacter = reader.characterAt(bufferColumn - 1) + const character = reader.characterAt(bufferColumn) + + if (firstNonWhitespaceScreenColumn < 0) { + if (character !== ' ' && character !== '\t') { + firstNonWhitespaceScreenColumn = expandedScreenColumn + } + } else if (consumedCharacterOnScreenRow && + previousCharacter && + character && + this.isWrapBoundary(previousCharacter, character)) { + lastWrapBoundaryUnexpandedScreenColumn = unexpandedScreenColumn + lastWrapBoundaryExpandedScreenColumn = expandedScreenColumn + tabCountBeforeLastWrapBoundary = tabCount + } + + let characterWidth + if (character === '\t') { + const distanceToNextTabStop = this.tabLength - (expandedScreenColumn % this.tabLength) + characterWidth = this.ratioForCharacter(' ') * distanceToNextTabStop + } else if (character) { + characterWidth = this.ratioForCharacter(character) + } else { + characterWidth = 0 + } + + if ( + consumedCharacterOnScreenRow && + screenLineWidth > 0 && characterWidth > 0 && + screenLineWidth + characterWidth > this.softWrapColumn && + previousCharacter && character && + !isCharacterPair(previousCharacter, character) + ) { + let nextIndentLength = (firstNonWhitespaceScreenColumn < this.softWrapColumn) + ? Math.max(0, firstNonWhitespaceScreenColumn) + : 0 + if (nextIndentLength + this.softWrapHangingIndent < this.softWrapColumn) { + nextIndentLength += this.softWrapHangingIndent + } + const wrapUnexpandedScreenColumn = lastWrapBoundaryUnexpandedScreenColumn || unexpandedScreenColumn + return { + wrapBufferColumn: bufferColumn - (unexpandedScreenColumn - wrapUnexpandedScreenColumn), + wrapUnexpandedScreenColumn, + screenLineLength: lastWrapBoundaryExpandedScreenColumn || expandedScreenColumn, + tabCount: lastWrapBoundaryUnexpandedScreenColumn ? tabCountBeforeLastWrapBoundary : tabCount, + indentLength: nextIndentLength, + firstNonWhitespaceScreenColumn + } + } + + if (character === '\t') { + tabCount++ + expandedScreenColumn += this.tabLength - (expandedScreenColumn % this.tabLength) + } else { + expandedScreenColumn++ + } + screenLineWidth += characterWidth + unexpandedScreenColumn++ + bufferColumn++ + consumedCharacterOnScreenRow = true + } + + return { + screenLineLength: expandedScreenColumn - 1, + tabCount, + firstNonWhitespaceScreenColumn } } diff --git a/src/screen-line-builder.js b/src/screen-line-builder.js index 7a1c5eedd..5fad15e64 100644 --- a/src/screen-line-builder.js +++ b/src/screen-line-builder.js @@ -1,6 +1,7 @@ /* eslint-disable no-labels */ const Point = require('./point') +const BufferRowReader = require('./buffer-row-reader') const HARD_TAB = 1 << 0 const LEADING_WHITESPACE = 1 << 2 @@ -23,26 +24,64 @@ class ScreenLineBuilder { this.requestedEndScreenRow = endScreenRow this.displayLayer.populateSpatialIndexIfNeeded(this.displayLayer.buffer.getLineCount(), endScreenRow) - this.bufferPosition = { - row: this.displayLayer.findBoundaryPrecedingBufferRow( - this.displayLayer.translateScreenPositionWithSpatialIndex(Point(startScreenRow, 0)).row - ), - column: 0 + // The indentation of the screen row we're about to build, if it's the + // continuation of a soft-wrapped buffer row; -1 otherwise. + let softWrapIndent = -1 + + // If the first requested screen row begins in the middle of a buffer row + // because of a soft wrap, start building there. Everything we need to + // resume — the buffer position and the indentation — is recorded on the + // soft-wrap hunk, so there's no need to rebuild the earlier screen rows of + // the buffer row. + const startHunk = startScreenRow > 0 + ? this.displayLayer.spatialIndex.changeForNewPosition(Point(startScreenRow, 0)) + : null + if ( + startHunk && + this.displayLayer.isSoftWrapHunk(startHunk) && + startHunk.newStart.row === startScreenRow - 1 && + startHunk.newEnd.row === startScreenRow && + // A fold ending on this buffer row would join it to an earlier one. + this.displayLayer.findBoundaryPrecedingBufferRow(startHunk.oldEnd.row) === startHunk.oldEnd.row + ) { + this.bufferPosition = {row: startHunk.oldEnd.row, column: startHunk.oldEnd.column} + this.screenRow = startScreenRow + softWrapIndent = startHunk.newEnd.column + } else { + this.bufferPosition = { + row: this.displayLayer.findBoundaryPrecedingBufferRow( + this.displayLayer.translateScreenPositionWithSpatialIndex(Point(startScreenRow, 0)).row + ), + column: 0 + } + this.screenRow = this.displayLayer.translateBufferPositionWithSpatialIndex(Point(this.bufferPosition.row, 0)).row } - this.screenRow = this.displayLayer.translateBufferPositionWithSpatialIndex(Point(this.bufferPosition.row, 0)).row - - const endBufferRow = this.displayLayer.translateScreenPositionWithSpatialIndex(Point(endScreenRow, Infinity)).row + // The buffer position at the end of the last screen row we'll build. The + // highlight iterator needs to report boundaries up to and including this + // position. (If that screen row ends at a soft wrap, the builder doesn't + // ask for boundaries at the wrap position, but those belong to the + // following screen row anyway.) + const endBufferPosition = this.displayLayer.buffer.clipPosition( + this.displayLayer.translateScreenPositionWithSpatialIndex(Point(Math.max(0, endScreenRow - 1), Infinity)) + ) let didSeekDecorationIterator = false const decorationIterator = this.displayLayer.buffer.languageMode.buildHighlightIterator() - const hunks = this.displayLayer.spatialIndex.getChangesInNewRange(Point(this.screenRow, 0), Point(endScreenRow, 0)) + const hunks = this.displayLayer.spatialIndex.getChangesInNewRange( + Point(this.screenRow, 0), + Point(endScreenRow, 0) + ) let hunkIndex = 0 + // Skip the soft-wrap hunk that ends at the start of our first screen row, + // if it was included. + while (hunkIndex < hunks.length && hunks[hunkIndex].newStart.row < this.screenRow) { + hunkIndex++ + } this.containingScopeIds = [] this.scopeIdsToReopen = [] this.screenLines = [] - this.bufferPosition.column = 0 this.beginLine() // Loop through all characters spanning the given screen row range, building @@ -60,6 +99,7 @@ class ScreenLineBuilder { if (nextHunk.newEnd.row > nextHunk.newStart.row) { this.screenRow++ this.bufferPosition.column = nextHunk.oldEnd.column + softWrapIndent = nextHunk.newEnd.column hunkIndex++ continue screenRowLoop } else { @@ -76,11 +116,13 @@ class ScreenLineBuilder { this.screenColumn = 0 this.bufferPosition.row++ this.bufferPosition.column = 0 + softWrapIndent = -1 continue } this.currentBuiltInClassNameFlags = 0 this.bufferLineLength = this.displayLayer.buffer.lineLengthForRow(this.bufferPosition.row) + this.bufferRowReader = new BufferRowReader(this.displayLayer.buffer, this.bufferPosition.row, this.bufferLineLength) if (this.bufferPosition.row > this.displayLayer.buffer.getLastRow()) break this.trailingWhitespaceStartColumn = this.displayLayer.findTrailingWhitespaceStartColumn(this.bufferPosition.row) @@ -89,13 +131,14 @@ class ScreenLineBuilder { if (!didSeekDecorationIterator || this.compareBufferPosition(decorationIterator.getPosition()) > 0) { didSeekDecorationIterator = true - this.scopeIdsToReopen = decorationIterator.seek(this.bufferPosition, endBufferRow) || [] + this.scopeIdsToReopen = decorationIterator.seek(this.bufferPosition, endBufferPosition) || [] } - var prevCachedScreenLine = this.displayLayer.cachedScreenLines[this.screenRow - 1] - if (prevCachedScreenLine && prevCachedScreenLine.softWrapIndent >= 0) { - this.inLeadingWhitespace = false - if (prevCachedScreenLine.softWrapIndent > 0) this.emitIndentWhitespace(prevCachedScreenLine.softWrapIndent) + // We're resuming partway through a soft-wrapped buffer row. + if (softWrapIndent >= 0) { + this.inLeadingWhitespace = this.isPrecededOnlyByWhitespace(this.bufferPosition) + if (softWrapIndent > 0) this.emitIndentWhitespace(softWrapIndent) + softWrapIndent = -1 } // This loop may visit multiple buffer rows if there are folds and @@ -109,15 +152,32 @@ class ScreenLineBuilder { if (this.screenRow === endScreenRow) { break screenRowLoop } + // If the next screen row is still cached, let the cached-row + // branch above skip it, just as it does for cached buffer rows. + // Otherwise an invalidated screen row in the middle of a long + // buffer row would cause every later screen row to be rebuilt + // (and get a new ID). Resuming afterward works the same way as + // starting the build partway through a buffer row, which also + // means it can't happen when a fold joins this buffer row to an + // earlier one. + if ( + this.displayLayer.cachedScreenLines[this.screenRow] && + this.displayLayer.findBoundaryPrecedingBufferRow(this.bufferPosition.row) === this.bufferPosition.row + ) { + softWrapIndent = nextHunk.newEnd.column + hunkIndex++ + continue screenRowLoop + } + this.emitIndentWhitespace(nextHunk.newEnd.column) } else { - this.emitFold(nextHunk, decorationIterator, endBufferRow) + this.emitFold(nextHunk, decorationIterator, endBufferPosition) } hunkIndex++ nextHunk = hunks[hunkIndex] } - var nextCharacter = this.displayLayer.buffer.getCharacterAtPosition(this.bufferPosition) + var nextCharacter = this.bufferRowReader.characterAt(this.bufferPosition.column) if (this.bufferPosition.column >= this.trailingWhitespaceStartColumn) { this.inTrailingWhitespace = true this.inLeadingWhitespace = false @@ -240,7 +300,7 @@ class ScreenLineBuilder { } } - emitFold (nextHunk, decorationIterator, endBufferRow) { + emitFold (nextHunk, decorationIterator, endBufferPosition) { this.emitCloseTag(this.getBuiltInScopeId(this.currentBuiltInClassNameFlags)) this.currentBuiltInClassNameFlags = 0 @@ -254,10 +314,15 @@ class ScreenLineBuilder { this.bufferPosition.row = nextHunk.oldEnd.row this.bufferPosition.column = nextHunk.oldEnd.column - this.scopeIdsToReopen = decorationIterator.seek(this.bufferPosition, endBufferRow) + this.scopeIdsToReopen = decorationIterator.seek(this.bufferPosition, endBufferPosition) this.bufferLineLength = this.displayLayer.buffer.lineLengthForRow(this.bufferPosition.row) + + this.bufferRowReader = new BufferRowReader(this.displayLayer.buffer, this.bufferPosition.row, this.bufferLineLength) this.trailingWhitespaceStartColumn = this.displayLayer.findTrailingWhitespaceStartColumn(this.bufferPosition.row) + // Whitespace that was trailing on the buffer row where the fold started + // says nothing about the buffer row where it ends. + this.inTrailingWhitespace = false } emitSoftWrap (nextHunk) { @@ -265,7 +330,6 @@ class ScreenLineBuilder { this.currentBuiltInClassNameFlags = 0 this.closeContainingScopes() this.emitNewline(nextHunk.newEnd.column) - this.emitIndentWhitespace(nextHunk.newEnd.column) } emitLineEnding () { @@ -422,6 +486,18 @@ class ScreenLineBuilder { } } + // When we start building partway through a buffer row, determine whether + // we're still in its leading whitespace (which can span soft wraps). + // Expects `this.bufferRowReader` to be reading `row`. + isPrecededOnlyByWhitespace ({row, column}) { + if (this.displayLayer.findBoundaryPrecedingBufferRow(row) !== row) return false + for (let c = 0; c < column; c++) { + const character = this.bufferRowReader.characterAt(c) + if (character !== ' ' && character !== '\t') return false + } + return true + } + compareBufferPosition (position) { const rowComparison = this.bufferPosition.row - position.row return rowComparison === 0 ? (this.bufferPosition.column - position.column) : rowComparison diff --git a/src/text-buffer.js b/src/text-buffer.js index 57120db58..f2fab0ce4 100644 --- a/src/text-buffer.js +++ b/src/text-buffer.js @@ -2192,6 +2192,10 @@ class TextBuffer { // * `buildHighlightIterator` - A function that returns an iterator object with the following // methods: // * `seek` A {Function} that takes a {Point} and resets the iterator to that position. + // It also receives a second {Point}: the buffer position at the end of the text that + // will be highlighted. The iterator must report boundaries up to and including that + // position, and may stop there. (Before text-buffer 16, this argument was a buffer + // row {Number}, meaning "through the end of this buffer row.") // * `moveToSuccessor` A {Function} that advances the iterator to the next token // * `getPosition` A {Function} that returns a {Point} representing the iterator's current // position in the buffer.