From 1dca81b275830e6780df7690269fe6c245ed275a Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Mon, 9 Nov 2026 10:08:01 -0800 Subject: [PATCH 1/5] Start building screen lines at the requested screen row When the first requested screen row is the continuation of a soft-wrapped buffer row, start building at the soft wrap instead of at the start of the buffer row. The soft-wrap hunk records the buffer position and indentation needed to resume, so there's no need to rebuild the earlier screen rows of a long buffer row. Resuming partway through a buffer row means working out whether we're still in its leading whitespace, which can span soft wraps. Previously a resumed screen row was always treated as past the leading whitespace. Also fix trailing-whitespace state leaking past a fold: whitespace that was trailing on the buffer row where a fold started says nothing about the buffer row where it ends. The randomized spec can now re-run a single seed via the SEED environment variable, and compares screen lines for a random range (with warm and cold caches) against a display layer that built every screen line from the top. --- spec/display-layer-spec.js | 28 ++++++++++++++-- src/screen-line-builder.js | 69 +++++++++++++++++++++++++++++++------- 2 files changed, 83 insertions(+), 14 deletions(-) diff --git a/spec/display-layer-spec.js b/spec/display-layer-spec.js index 07ff4c7ed7..3c8fc3bce3 100644 --- a/spec/display-layer-spec.js +++ b/spec/display-layer-spec.js @@ -2478,10 +2478,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 +2565,7 @@ describe('DisplayLayer', () => { } verifyTokenConsistency(displayLayer) verifyText(random, displayLayer, freshDisplayLayer) + verifyScreenLinesForRandomRange(random, displayLayer, freshDisplayLayer) verifyRightmostScreenPosition(freshDisplayLayer) verifyScreenLineIds(displayLayer, screenLinesById) verifyPositionTranslations(random, displayLayer) @@ -2672,6 +2675,27 @@ function verifyText (random, displayLayer, freshDisplayLayer) { expect(JSON.stringify(text)).toBe(JSON.stringify(expectedText), `Text for rows ${startRow} - ${endRow}`) } +// 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/src/screen-line-builder.js b/src/screen-line-builder.js index 7a1c5eedd6..463d45b95a 100644 --- a/src/screen-line-builder.js +++ b/src/screen-line-builder.js @@ -23,26 +23,52 @@ 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 let didSeekDecorationIterator = false const decorationIterator = this.displayLayer.buffer.languageMode.buildHighlightIterator() 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 +86,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,6 +103,7 @@ class ScreenLineBuilder { this.screenColumn = 0 this.bufferPosition.row++ this.bufferPosition.column = 0 + softWrapIndent = -1 continue } @@ -92,10 +120,11 @@ class ScreenLineBuilder { this.scopeIdsToReopen = decorationIterator.seek(this.bufferPosition, endBufferRow) || [] } - 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 @@ -258,6 +287,9 @@ class ScreenLineBuilder { this.bufferLineLength = this.displayLayer.buffer.lineLengthForRow(this.bufferPosition.row) 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) { @@ -422,6 +454,19 @@ 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). + isPrecededOnlyByWhitespace ({row, column}) { + if (this.displayLayer.findBoundaryPrecedingBufferRow(row) !== row) return false + const buffer = this.displayLayer.buffer + const position = {row, column: 0} + for (; position.column < column; position.column++) { + const character = buffer.getCharacterAtPosition(position) + 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 From 6722d1032ebee026410d09db1d7f60762353ea73 Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Mon, 9 Nov 2026 10:08:50 -0800 Subject: [PATCH 2/5] Re-wrap only the affected screen rows after an edit within a buffer row When soft wrap is on and an edit stays within one buffer row (no newlines inserted or removed, no folds or indent-guide complications), update the spatial index for just the part of the buffer row the edit can affect instead of re-wrapping the whole buffer row. The re-wrap starts on the screen row whose overflow check could read the edited text. That's usually the edit's own screen row or the one above, but the first screen row has no indentation and so may reach further, and hard tabs break the reasoning entirely, so either case is checked explicitly. Re-wrapping stops at the first new soft wrap after the edit that matches an old one shifted by the edit's length. Callers are still told the whole buffer row changed, and its cached screen lines are cleared, because language modes don't report every highlighting change within an edited buffer row. `BufferRowReader` reads a buffer row a chunk at a time, so working on part of a long buffer row doesn't require fetching all of it. This changes layout slightly: after inserting a soft wrap, the scanner now rewinds to the wrap's buffer column and rescans in the new screen row's context. Previously, characters already measured on the old screen row were carried over, so a hard tab carried onto a new screen row could be measured at the wrong tab stop. The fast path depends on this, since both paths must produce the same layout. --- spec/display-layer-spec.js | 154 +++++++++++++ src/buffer-row-reader.js | 36 +++ src/display-layer.js | 438 ++++++++++++++++++++++++++++++++++++- 3 files changed, 619 insertions(+), 9 deletions(-) create mode 100644 src/buffer-row-reader.js diff --git a/spec/display-layer-spec.js b/spec/display-layer-spec.js index 3c8fc3bce3..9c9fe2768a 100644 --- a/spec/display-layer-spec.js +++ b/spec/display-layer-spec.js @@ -2566,6 +2566,13 @@ 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) @@ -2581,8 +2588,143 @@ 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) + if (random(2)) buffer.setLanguageMode(new TestLanguageMode([], buffer, random)) + + 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() + } + + 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) @@ -2675,6 +2817,18 @@ 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 diff --git a/src/buffer-row-reader.js b/src/buffer-row-reader.js new file mode 100644 index 0000000000..edda9f0b79 --- /dev/null +++ b/src/buffer-row-reader.js @@ -0,0 +1,36 @@ +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. +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] + + // 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 705002185e..897f8ed9a8 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') @@ -785,7 +786,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 +879,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 +900,8 @@ class DisplayLayer { firstNonWhitespaceScreenColumn = expandedScreenColumn } } else { - if (previousCharacter && + if (consumedCharacterOnScreenRow && + previousCharacter && character && this.isWrapBoundary(previousCharacter, character)) { lastWrapBoundaryUnexpandedScreenColumn = unexpandedScreenColumn @@ -906,6 +922,7 @@ class DisplayLayer { } const insertSoftLineBreak = + consumedCharacterOnScreenRow && screenLineWidth > 0 && characterWidth > 0 && screenLineWidth + characterWidth > this.softWrapColumn && previousCharacter && character && @@ -935,9 +952,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 +1026,7 @@ class DisplayLayer { traversal(foldEnd, {row: bufferRow, column: bufferColumn}), {row: 0, column: 1} ) + foldUnexpandedScreenColumn = unexpandedScreenColumn unexpandedScreenColumn++ expandedScreenColumn++ screenLineWidth += characterWidth @@ -995,6 +1049,7 @@ class DisplayLayer { unexpandedScreenColumn++ bufferColumn++ } + consumedCharacterOnScreenRow = true } expandedScreenColumn-- @@ -1034,11 +1089,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 +1126,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 } } From 73b5c37bb0836bb8b1283abb9028d3afc6e062bd Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Mon, 9 Nov 2026 10:09:37 -0800 Subject: [PATCH 3/5] Read buffer text in chunks when building screen lines and expanding tabs The screen line builder called `getCharacterAtPosition` for every character, which allocates a `Point` and crosses into superstring each time. Read through a `BufferRowReader` instead. On a 1M-character buffer row, rendering 50 soft- wrapped screen rows drops from about 6ms to 1.2ms, and ordinary short buffer rows get faster too. `expandHardTabs` and `collapseHardTabs` fetched the whole buffer row with `lineForRow` to scan one screen row of it. Read only as far as the scan goes. With soft wrap on, translating a position on a long buffer row with hard tabs no longer costs time proportional to the buffer row's length. `BufferRowReader` now continues from the end of its chunk when reading forward, rather than re-reading some text before the requested column. --- src/buffer-row-reader.js | 14 ++++++++++---- src/display-layer.js | 12 ++++++------ src/screen-line-builder.js | 13 ++++++++----- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/buffer-row-reader.js b/src/buffer-row-reader.js index edda9f0b79..566601d898 100644 --- a/src/buffer-row-reader.js +++ b/src/buffer-row-reader.js @@ -4,7 +4,8 @@ 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. +// 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 @@ -21,9 +22,14 @@ class BufferRowReader { const index = column - this.chunkStart if (index >= 0 && index < this.chunk.length) return this.chunk[index] - // 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)) + 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), diff --git a/src/display-layer.js b/src/display-layer.js index 897f8ed9a8..db9f0e8e74 100644 --- a/src/display-layer.js +++ b/src/display-layer.js @@ -442,7 +442,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 (unexpandedScreenColumn === targetScreenPosition.column) { @@ -457,7 +457,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++ } @@ -466,7 +466,7 @@ class DisplayLayer { continue } - if (bufferLine[bufferColumn] === '\t') { + if (reader.characterAt(bufferColumn) === '\t') { expandedScreenColumn += (this.tabLength - (expandedScreenColumn % this.tabLength)) tabCount-- } else { @@ -493,7 +493,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) { @@ -508,7 +508,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++ } @@ -516,7 +516,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') { diff --git a/src/screen-line-builder.js b/src/screen-line-builder.js index 463d45b95a..9fd6c08519 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 @@ -109,6 +110,7 @@ class ScreenLineBuilder { 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) @@ -146,7 +148,7 @@ class ScreenLineBuilder { 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 @@ -286,6 +288,8 @@ class ScreenLineBuilder { this.scopeIdsToReopen = decorationIterator.seek(this.bufferPosition, endBufferRow) 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. @@ -456,12 +460,11 @@ 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 - const buffer = this.displayLayer.buffer - const position = {row, column: 0} - for (; position.column < column; position.column++) { - const character = buffer.getCharacterAtPosition(position) + for (let c = 0; c < column; c++) { + const character = this.bufferRowReader.characterAt(c) if (character !== ' ' && character !== '\t') return false } return true From 097dac66cf2e024a7b082c64eecf1a2f2a122cc7 Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Mon, 9 Nov 2026 10:10:15 -0800 Subject: [PATCH 4/5] Invalidate highlighting by screen row rather than by buffer row When the language mode reports a highlighting change, clear the cached screen lines and report changes only for the screen rows that contain part of the range, rather than for every screen row of the buffer rows it touches. To keep the cached screen rows that follow an invalidated one in the same buffer row, the screen line builder now skips a cached screen row after a soft wrap, as it already did for cached buffer rows. Otherwise every later screen row would be rebuilt and get a new ID. The second argument to `HighlightIterator#seek` is now a buffer position (the end of the last screen row being built) instead of a buffer row number, so a language mode can limit its work to what's being rendered. This is a breaking change for language modes that use the argument. --- spec/display-layer-spec.js | 48 +++++++++++++++++++++++++++++- spec/helpers/test-language-mode.js | 8 ++--- src/display-layer.js | 19 ++++++++---- src/screen-line-builder.js | 35 ++++++++++++++++++---- src/text-buffer.js | 4 +++ 5 files changed, 98 insertions(+), 16 deletions(-) diff --git a/spec/display-layer-spec.js b/spec/display-layer-spec.js index 9c9fe2768a..1be4609713 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' @@ -2633,7 +2663,8 @@ describe('DisplayLayer', () => { character === '中' } const displayLayer = buffer.addDisplayLayer(params) - if (random(2)) buffer.setLanguageMode(new TestLanguageMode([], buffer, random)) + const languageMode = random(2) ? new TestLanguageMode([], buffer, random) : null + if (languageMode) buffer.setLanguageMode(languageMode) const updateWithinBufferRow = displayLayer.updateSpatialIndexForEditWithinBufferRow displayLayer.updateSpatialIndexForEditWithinBufferRow = function (change) { @@ -2667,6 +2698,21 @@ describe('DisplayLayer', () => { 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) diff --git a/spec/helpers/test-language-mode.js b/spec/helpers/test-language-mode.js index 2eb3e01e49..834f5d1a23 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/display-layer.js b/src/display-layer.js index db9f0e8e74..a0d6062937 100644 --- a/src/display-layer.js +++ b/src/display-layer.js @@ -148,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({ diff --git a/src/screen-line-builder.js b/src/screen-line-builder.js index 9fd6c08519..f8ddca05c5 100644 --- a/src/screen-line-builder.js +++ b/src/screen-line-builder.js @@ -57,7 +57,14 @@ class ScreenLineBuilder { 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() @@ -119,7 +126,7 @@ 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) || [] } // We're resuming partway through a soft-wrapped buffer row. @@ -140,8 +147,25 @@ 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++ @@ -271,7 +295,7 @@ class ScreenLineBuilder { } } - emitFold (nextHunk, decorationIterator, endBufferRow) { + emitFold (nextHunk, decorationIterator, endBufferPosition) { this.emitCloseTag(this.getBuiltInScopeId(this.currentBuiltInClassNameFlags)) this.currentBuiltInClassNameFlags = 0 @@ -285,7 +309,7 @@ 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) @@ -301,7 +325,6 @@ class ScreenLineBuilder { this.currentBuiltInClassNameFlags = 0 this.closeContainingScopes() this.emitNewline(nextHunk.newEnd.column) - this.emitIndentWhitespace(nextHunk.newEnd.column) } emitLineEnding () { diff --git a/src/text-buffer.js b/src/text-buffer.js index 57120db586..f2fab0ce4d 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. From 756557a9798fda12f08456db8a40fefbb653bc5e Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Mon, 9 Nov 2026 13:34:20 -0800 Subject: [PATCH 5/5] =?UTF-8?q?Remove=20an=20unnecessary=20parameter=20fro?= =?UTF-8?q?m=20`expandHardTabs`=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …and do some code reformatting. --- src/display-layer.js | 4 ++-- src/screen-line-builder.js | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/display-layer.js b/src/display-layer.js index a0d6062937..b40cb3fc50 100644 --- a/src/display-layer.js +++ b/src/display-layer.js @@ -308,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) @@ -444,7 +444,7 @@ 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 diff --git a/src/screen-line-builder.js b/src/screen-line-builder.js index f8ddca05c5..5fad15e640 100644 --- a/src/screen-line-builder.js +++ b/src/screen-line-builder.js @@ -68,11 +68,16 @@ class ScreenLineBuilder { 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++ + while (hunkIndex < hunks.length && hunks[hunkIndex].newStart.row < this.screenRow) { + hunkIndex++ + } this.containingScopeIds = [] this.scopeIdsToReopen = []