mirror of
https://github.com/pulsar-edit/pulsar.git
synced 2024-09-20 15:37:46 +03:00
Fix lurking soft-wrap bugs
This commit adds two important things: 1. An editor spec that randomly mutates a buffer and toggles soft wrap on and off, then compares the screen lines to a simple reference implementation to ensure everything stays in a correct state. 2. A new and radically simpler implementation of RowMap that eliminates failures in the randomized test.
This commit is contained in:
parent
b97db1914f
commit
c3f995b165
@ -143,5 +143,8 @@
|
||||
"scripts": {
|
||||
"preinstall": "node -e 'process.exit(0)'",
|
||||
"test": "node script/test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"random-words": "0.0.1"
|
||||
}
|
||||
}
|
||||
|
112
spec/random-editor-spec.coffee
Normal file
112
spec/random-editor-spec.coffee
Normal file
@ -0,0 +1,112 @@
|
||||
{times, random} = require 'underscore-plus'
|
||||
randomWords = require 'random-words'
|
||||
Editor = require '../src/editor'
|
||||
TextBuffer = require '../src/text-buffer'
|
||||
|
||||
describe "Editor", ->
|
||||
[editor, tokenizedBuffer, buffer, steps, previousSteps] = []
|
||||
|
||||
softWrapColumn = 80
|
||||
|
||||
beforeEach ->
|
||||
atom.config.set('editor.softWrapAtPreferredLineLength', true)
|
||||
atom.config.set('editor.preferredLineLength', softWrapColumn)
|
||||
|
||||
it "properly renders soft-wrapped lines when randomly mutated", ->
|
||||
previousSteps = JSON.parse(localStorage.steps ? '[]')
|
||||
|
||||
times 10, (i) ->
|
||||
buffer = new TextBuffer
|
||||
editor = new Editor({buffer})
|
||||
editor.setEditorWidthInChars(80)
|
||||
tokenizedBuffer = editor.displayBuffer.tokenizedBuffer
|
||||
steps = []
|
||||
|
||||
times 30, ->
|
||||
randomlyMutateEditor()
|
||||
verifyLines()
|
||||
|
||||
verifyLines = ->
|
||||
{bufferRows, screenLines} = getReferenceScreenLines()
|
||||
for referenceBufferRow, screenRow in bufferRows
|
||||
referenceScreenLine = screenLines[screenRow]
|
||||
actualBufferRow = editor.bufferRowForScreenRow(screenRow)
|
||||
unless actualBufferRow is referenceBufferRow
|
||||
logLines()
|
||||
throw new Error("Invalid buffer row #{actualBufferRow} for screen row #{screenRow}", )
|
||||
|
||||
actualScreenLine = editor.lineForScreenRow(screenRow)
|
||||
unless actualScreenLine.text is referenceScreenLine.text
|
||||
logLines()
|
||||
throw new Error("Invalid line text at screen row #{screenRow}")
|
||||
|
||||
logLines = ->
|
||||
console.log "==== screen lines ===="
|
||||
editor.logScreenLines()
|
||||
console.log "==== reference lines ===="
|
||||
{bufferRows, screenLines} = getReferenceScreenLines()
|
||||
for bufferRow, screenRow in bufferRows
|
||||
console.log screenRow, bufferRow, screenLines[screenRow].text
|
||||
|
||||
randomlyMutateEditor = ->
|
||||
if Math.random() < .2
|
||||
softWrap = not editor.getSoftWrap()
|
||||
steps.push(['setSoftWrap', softWrap])
|
||||
editor.setSoftWrap(softWrap)
|
||||
else
|
||||
range = getRandomRange()
|
||||
text = getRandomText()
|
||||
steps.push(['setTextInBufferRange', range, text])
|
||||
editor.setTextInBufferRange(range, text)
|
||||
|
||||
getRandomRange = ->
|
||||
startRow = random(0, buffer.getLastRow())
|
||||
startColumn = random(0, buffer.lineForRow(startRow).length)
|
||||
endRow = random(startRow, buffer.getLastRow())
|
||||
endColumn = random(0, buffer.lineForRow(endRow).length)
|
||||
[[startRow, startColumn], [endRow, endColumn]]
|
||||
|
||||
getRandomText = ->
|
||||
text = []
|
||||
max = buffer.getText().split(/\s/).length * 0.75
|
||||
|
||||
times random(5, max), ->
|
||||
if Math.random() < .1
|
||||
text += '\n'
|
||||
else
|
||||
text += " " if /\w$/.test(text)
|
||||
text += randomWords(exactly: 1)
|
||||
text
|
||||
|
||||
getReferenceScreenLines = ->
|
||||
if editor.getSoftWrap()
|
||||
screenLines = []
|
||||
bufferRows = []
|
||||
for bufferRow in [0..tokenizedBuffer.getLastRow()]
|
||||
for screenLine in softWrapLine(tokenizedBuffer.lineForScreenRow(bufferRow))
|
||||
screenLines.push(screenLine)
|
||||
bufferRows.push(bufferRow)
|
||||
else
|
||||
screenLines = tokenizedBuffer.tokenizedLines.slice()
|
||||
bufferRows = [0..tokenizedBuffer.getLastRow()]
|
||||
{screenLines, bufferRows}
|
||||
|
||||
softWrapLine = (tokenizedLine) ->
|
||||
wrappedLines = []
|
||||
while tokenizedLine.text.length > softWrapColumn and wrapScreenColumn = findWrapColumn(tokenizedLine.text)
|
||||
[wrappedLine, tokenizedLine] = tokenizedLine.softWrapAt(wrapScreenColumn)
|
||||
wrappedLines.push(wrappedLine)
|
||||
wrappedLines.push(tokenizedLine)
|
||||
wrappedLines
|
||||
|
||||
findWrapColumn = (line) ->
|
||||
if /\s/.test(line[softWrapColumn])
|
||||
# search forward for the start of a word past the boundary
|
||||
for column in [softWrapColumn..line.length]
|
||||
return column if /\S/.test(line[column])
|
||||
return line.length
|
||||
else
|
||||
# search backward for the start of the word on the boundary
|
||||
for column in [softWrapColumn..0]
|
||||
return column + 1 if /\s/.test(line[column])
|
||||
return softWrapColumn
|
@ -6,256 +6,101 @@ describe "RowMap", ->
|
||||
beforeEach ->
|
||||
map = new RowMap
|
||||
|
||||
describe "when no mappings have been recorded", ->
|
||||
it "maps buffer rows to screen rows 1:1", ->
|
||||
describe "::screenRowRangeForBufferRow(bufferRow)", ->
|
||||
it "returns the range of screen rows corresponding to the given buffer row", ->
|
||||
map.spliceRegions(0, 0, [
|
||||
{bufferRows: 5, screenRows: 5}
|
||||
{bufferRows: 1, screenRows: 5}
|
||||
{bufferRows: 5, screenRows: 5}
|
||||
{bufferRows: 5, screenRows: 1}
|
||||
])
|
||||
|
||||
expect(map.screenRowRangeForBufferRow(0)).toEqual [0, 1]
|
||||
expect(map.screenRowRangeForBufferRow(100)).toEqual [100, 101]
|
||||
expect(map.screenRowRangeForBufferRow(5)).toEqual [5, 10]
|
||||
expect(map.screenRowRangeForBufferRow(6)).toEqual [10, 11]
|
||||
expect(map.screenRowRangeForBufferRow(11)).toEqual [15, 16]
|
||||
expect(map.screenRowRangeForBufferRow(12)).toEqual [15, 16]
|
||||
expect(map.screenRowRangeForBufferRow(16)).toEqual [16, 17]
|
||||
|
||||
describe ".mapBufferRowRange(startBufferRow, endBufferRow, screenRows)", ->
|
||||
describe "when mapping to a single screen row (like a visible fold)", ->
|
||||
beforeEach ->
|
||||
map.mapBufferRowRange(5, 10, 1)
|
||||
map.mapBufferRowRange(15, 20, 1)
|
||||
map.mapBufferRowRange(25, 30, 1)
|
||||
describe "::bufferRowRangeForScreenRow(screenRow)", ->
|
||||
it "returns the range of buffer rows corresponding to the given screen row", ->
|
||||
map.spliceRegions(0, 0, [
|
||||
{bufferRows: 5, screenRows: 5}
|
||||
{bufferRows: 1, screenRows: 5}
|
||||
{bufferRows: 5, screenRows: 5}
|
||||
{bufferRows: 5, screenRows: 1}
|
||||
])
|
||||
|
||||
it "accounts for the mapping when translating buffer rows to screen row ranges", ->
|
||||
expect(map.screenRowRangeForBufferRow(0)).toEqual [0, 1]
|
||||
expect(map.bufferRowRangeForScreenRow(0)).toEqual [0, 1]
|
||||
expect(map.bufferRowRangeForScreenRow(5)).toEqual [5, 6]
|
||||
expect(map.bufferRowRangeForScreenRow(6)).toEqual [5, 6]
|
||||
expect(map.bufferRowRangeForScreenRow(10)).toEqual [6, 7]
|
||||
expect(map.bufferRowRangeForScreenRow(14)).toEqual [10, 11]
|
||||
expect(map.bufferRowRangeForScreenRow(15)).toEqual [11, 16]
|
||||
expect(map.bufferRowRangeForScreenRow(16)).toEqual [16, 17]
|
||||
|
||||
expect(map.screenRowRangeForBufferRow(4)).toEqual [4, 5]
|
||||
expect(map.screenRowRangeForBufferRow(5)).toEqual [5, 6]
|
||||
expect(map.screenRowRangeForBufferRow(9)).toEqual [5, 6]
|
||||
expect(map.screenRowRangeForBufferRow(10)).toEqual [6, 7]
|
||||
describe "::spliceRegions(startBufferRow, bufferRowCount, regions)", ->
|
||||
it "can insert regions when empty", ->
|
||||
regions = [
|
||||
{bufferRows: 5, screenRows: 5}
|
||||
{bufferRows: 1, screenRows: 5}
|
||||
{bufferRows: 5, screenRows: 5}
|
||||
{bufferRows: 5, screenRows: 1}
|
||||
]
|
||||
map.spliceRegions(0, 0, regions)
|
||||
expect(map.getRegions()).toEqual regions
|
||||
|
||||
expect(map.screenRowRangeForBufferRow(14)).toEqual [10, 11]
|
||||
expect(map.screenRowRangeForBufferRow(15)).toEqual [11, 12]
|
||||
expect(map.screenRowRangeForBufferRow(19)).toEqual [11, 12]
|
||||
expect(map.screenRowRangeForBufferRow(20)).toEqual [12, 13]
|
||||
it "can insert wrapped lines into rectangular regions", ->
|
||||
map.spliceRegions(0, 0, [{bufferRows: 10, screenRows: 10}])
|
||||
map.spliceRegions(5, 0, [{bufferRows: 1, screenRows: 3}])
|
||||
expect(map.getRegions()).toEqual [
|
||||
{bufferRows: 5, screenRows: 5}
|
||||
{bufferRows: 1, screenRows: 3}
|
||||
{bufferRows: 5, screenRows: 5}
|
||||
]
|
||||
|
||||
expect(map.screenRowRangeForBufferRow(24)).toEqual [16, 17]
|
||||
expect(map.screenRowRangeForBufferRow(25)).toEqual [17, 18]
|
||||
expect(map.screenRowRangeForBufferRow(29)).toEqual [17, 18]
|
||||
expect(map.screenRowRangeForBufferRow(30)).toEqual [18, 19]
|
||||
it "can splice wrapped lines into rectangular regions", ->
|
||||
map.spliceRegions(0, 0, [{bufferRows: 10, screenRows: 10}])
|
||||
map.spliceRegions(5, 1, [{bufferRows: 1, screenRows: 3}])
|
||||
expect(map.getRegions()).toEqual [
|
||||
{bufferRows: 5, screenRows: 5}
|
||||
{bufferRows: 1, screenRows: 3}
|
||||
{bufferRows: 4, screenRows: 4}
|
||||
]
|
||||
|
||||
it "accounts for the mapping when translating screen rows to buffer row ranges", ->
|
||||
expect(map.bufferRowRangeForScreenRow(0)).toEqual [0, 1]
|
||||
it "can splice folded lines into rectangular regions", ->
|
||||
map.spliceRegions(0, 0, [{bufferRows: 10, screenRows: 10}])
|
||||
map.spliceRegions(5, 3, [{bufferRows: 3, screenRows: 1}])
|
||||
expect(map.getRegions()).toEqual [
|
||||
{bufferRows: 5, screenRows: 5}
|
||||
{bufferRows: 3, screenRows: 1}
|
||||
{bufferRows: 2, screenRows: 2}
|
||||
]
|
||||
|
||||
expect(map.bufferRowRangeForScreenRow(4)).toEqual [4, 5]
|
||||
expect(map.bufferRowRangeForScreenRow(5)).toEqual [5, 10]
|
||||
expect(map.bufferRowRangeForScreenRow(6)).toEqual [10, 11]
|
||||
it "can replace folded regions with a folded region that surrounds them", ->
|
||||
map.spliceRegions(0, 0, [
|
||||
{bufferRows: 3, screenRows: 3}
|
||||
{bufferRows: 3, screenRows: 1}
|
||||
{bufferRows: 1, screenRows: 1}
|
||||
{bufferRows: 3, screenRows: 1}
|
||||
{bufferRows: 3, screenRows: 3}
|
||||
])
|
||||
map.spliceRegions(2, 8, [{bufferRows: 8, screenRows: 1}])
|
||||
expect(map.getRegions()).toEqual [
|
||||
{bufferRows: 2, screenRows: 2}
|
||||
{bufferRows: 8, screenRows: 1}
|
||||
{bufferRows: 3, screenRows: 3}
|
||||
]
|
||||
|
||||
expect(map.bufferRowRangeForScreenRow(10)).toEqual [14, 15]
|
||||
expect(map.bufferRowRangeForScreenRow(11)).toEqual [15, 20]
|
||||
expect(map.bufferRowRangeForScreenRow(12)).toEqual [20, 21]
|
||||
it "merges adjacent rectangular regions", ->
|
||||
map.spliceRegions(0, 0, [
|
||||
{bufferRows: 3, screenRows: 3}
|
||||
{bufferRows: 3, screenRows: 1}
|
||||
{bufferRows: 1, screenRows: 1}
|
||||
{bufferRows: 3, screenRows: 1}
|
||||
{bufferRows: 3, screenRows: 3}
|
||||
])
|
||||
|
||||
expect(map.bufferRowRangeForScreenRow(16)).toEqual [24, 25]
|
||||
expect(map.bufferRowRangeForScreenRow(17)).toEqual [25, 30]
|
||||
expect(map.bufferRowRangeForScreenRow(18)).toEqual [30, 31]
|
||||
map.spliceRegions(3, 7, [{bufferRows: 5, screenRows: 5}])
|
||||
|
||||
describe "when mapping to zero screen rows (like an invisible fold)", ->
|
||||
beforeEach ->
|
||||
map.mapBufferRowRange(5, 10, 0)
|
||||
map.mapBufferRowRange(15, 20, 0)
|
||||
map.mapBufferRowRange(25, 30, 0)
|
||||
|
||||
it "accounts for the mapping when translating buffer rows to screen row ranges", ->
|
||||
expect(map.screenRowRangeForBufferRow(0)).toEqual [0, 1]
|
||||
|
||||
expect(map.screenRowRangeForBufferRow(4)).toEqual [4, 5]
|
||||
expect(map.screenRowRangeForBufferRow(5)).toEqual [5, 5]
|
||||
expect(map.screenRowRangeForBufferRow(9)).toEqual [5, 5]
|
||||
expect(map.screenRowRangeForBufferRow(10)).toEqual [5, 6]
|
||||
|
||||
expect(map.screenRowRangeForBufferRow(14)).toEqual [9, 10]
|
||||
expect(map.screenRowRangeForBufferRow(15)).toEqual [10, 10]
|
||||
expect(map.screenRowRangeForBufferRow(19)).toEqual [10, 10]
|
||||
expect(map.screenRowRangeForBufferRow(20)).toEqual [10, 11]
|
||||
|
||||
expect(map.screenRowRangeForBufferRow(24)).toEqual [14, 15]
|
||||
expect(map.screenRowRangeForBufferRow(25)).toEqual [15, 15]
|
||||
expect(map.screenRowRangeForBufferRow(29)).toEqual [15, 15]
|
||||
expect(map.screenRowRangeForBufferRow(30)).toEqual [15, 16]
|
||||
|
||||
it "accounts for the mapping when translating screen rows to buffer row ranges", ->
|
||||
expect(map.bufferRowRangeForScreenRow(0)).toEqual [0, 1]
|
||||
|
||||
expect(map.bufferRowRangeForScreenRow(4)).toEqual [4, 5]
|
||||
expect(map.bufferRowRangeForScreenRow(5)).toEqual [10, 11]
|
||||
|
||||
expect(map.bufferRowRangeForScreenRow(9)).toEqual [14, 15]
|
||||
expect(map.bufferRowRangeForScreenRow(10)).toEqual [20, 21]
|
||||
|
||||
expect(map.bufferRowRangeForScreenRow(14)).toEqual [24, 25]
|
||||
expect(map.bufferRowRangeForScreenRow(15)).toEqual [30, 31]
|
||||
|
||||
describe "when mapping a single buffer row to multiple screen rows (like a wrapped line)", ->
|
||||
beforeEach ->
|
||||
map.mapBufferRowRange(5, 6, 3)
|
||||
map.mapBufferRowRange(10, 11, 2)
|
||||
map.mapBufferRowRange(20, 21, 5)
|
||||
|
||||
it "accounts for the mapping when translating buffer rows to screen row ranges", ->
|
||||
expect(map.screenRowRangeForBufferRow(0)).toEqual [0, 1]
|
||||
|
||||
expect(map.screenRowRangeForBufferRow(4)).toEqual [4, 5]
|
||||
expect(map.screenRowRangeForBufferRow(5)).toEqual [5, 8]
|
||||
expect(map.screenRowRangeForBufferRow(6)).toEqual [8, 9]
|
||||
|
||||
expect(map.screenRowRangeForBufferRow(9)).toEqual [11, 12]
|
||||
expect(map.screenRowRangeForBufferRow(10)).toEqual [12, 14]
|
||||
expect(map.screenRowRangeForBufferRow(11)).toEqual [14, 15]
|
||||
|
||||
expect(map.screenRowRangeForBufferRow(19)).toEqual [22, 23]
|
||||
expect(map.screenRowRangeForBufferRow(20)).toEqual [23, 28]
|
||||
expect(map.screenRowRangeForBufferRow(21)).toEqual [28, 29]
|
||||
|
||||
it "accounts for the mapping when translating screen rows to buffer row ranges", ->
|
||||
expect(map.bufferRowRangeForScreenRow(0)).toEqual [0, 1]
|
||||
|
||||
expect(map.bufferRowRangeForScreenRow(4)).toEqual [4, 5]
|
||||
expect(map.bufferRowRangeForScreenRow(5)).toEqual [5, 6]
|
||||
expect(map.bufferRowRangeForScreenRow(7)).toEqual [5, 6]
|
||||
expect(map.bufferRowRangeForScreenRow(8)).toEqual [6, 7]
|
||||
|
||||
expect(map.bufferRowRangeForScreenRow(11)).toEqual [9, 10]
|
||||
expect(map.bufferRowRangeForScreenRow(12)).toEqual [10, 11]
|
||||
expect(map.bufferRowRangeForScreenRow(13)).toEqual [10, 11]
|
||||
expect(map.bufferRowRangeForScreenRow(14)).toEqual [11, 12]
|
||||
|
||||
expect(map.bufferRowRangeForScreenRow(22)).toEqual [19, 20]
|
||||
expect(map.bufferRowRangeForScreenRow(23)).toEqual [20, 21]
|
||||
expect(map.bufferRowRangeForScreenRow(27)).toEqual [20, 21]
|
||||
expect(map.bufferRowRangeForScreenRow(28)).toEqual [21, 22]
|
||||
|
||||
describe "after re-mapping a row range to a new number of screen rows", ->
|
||||
beforeEach ->
|
||||
map.applyScreenDelta(12, 2) # simulate adding 2 more soft wraps
|
||||
map.mapBufferRowRange(10, 11, 4)
|
||||
|
||||
it "updates translation accordingly", ->
|
||||
expect(map.screenRowRangeForBufferRow(4)).toEqual [4, 5]
|
||||
expect(map.screenRowRangeForBufferRow(5)).toEqual [5, 8]
|
||||
expect(map.screenRowRangeForBufferRow(6)).toEqual [8, 9]
|
||||
|
||||
expect(map.screenRowRangeForBufferRow(9)).toEqual [11, 12]
|
||||
expect(map.screenRowRangeForBufferRow(10)).toEqual [12, 16]
|
||||
expect(map.screenRowRangeForBufferRow(11)).toEqual [16, 17]
|
||||
|
||||
expect(map.screenRowRangeForBufferRow(19)).toEqual [24, 25]
|
||||
expect(map.screenRowRangeForBufferRow(20)).toEqual [25, 30]
|
||||
expect(map.screenRowRangeForBufferRow(21)).toEqual [30, 31]
|
||||
|
||||
describe "when the row range is inside an existing 1:1 region", ->
|
||||
it "preserves the starting screen row of subsequent 1:N regions", ->
|
||||
map.mapBufferRowRange(5, 10, 1)
|
||||
map.mapBufferRowRange(25, 30, 1)
|
||||
|
||||
expect(map.bufferRowRangeForScreenRow(5)).toEqual [5, 10]
|
||||
expect(map.bufferRowRangeForScreenRow(21)).toEqual [25, 30]
|
||||
|
||||
map.mapBufferRowRange(15, 20, 1)
|
||||
|
||||
expect(map.bufferRowRangeForScreenRow(11)).toEqual [15, 20]
|
||||
expect(map.bufferRowRangeForScreenRow(5)).toEqual [5, 10]
|
||||
expect(map.bufferRowRangeForScreenRow(21)).toEqual [25, 30]
|
||||
|
||||
describe "when the row range surrounds existing regions", ->
|
||||
it "replaces the regions inside the given buffer row range with a single region", ->
|
||||
map.mapBufferRowRange(5, 10, 1) # inner fold 1
|
||||
map.mapBufferRowRange(11, 13, 1) # inner fold 2
|
||||
map.mapBufferRowRange(15, 20, 1) # inner fold 3
|
||||
map.mapBufferRowRange(22, 27, 1) # following fold
|
||||
|
||||
map.mapBufferRowRange(5, 20, 1)
|
||||
|
||||
expect(map.bufferRowRangeForScreenRow(5)).toEqual [5, 20]
|
||||
expect(map.bufferRowRangeForScreenRow(6)).toEqual [20, 21]
|
||||
expect(map.bufferRowRangeForScreenRow(7)).toEqual [21, 22]
|
||||
expect(map.bufferRowRangeForScreenRow(8)).toEqual [22, 27]
|
||||
|
||||
it "replaces regions that cover 0 buffer rows at the start or end of the buffer row range", ->
|
||||
map.mapBufferRowRange(0, 0, 1)
|
||||
map.mapBufferRowRange(0, 1, 1)
|
||||
map.mapBufferRowRange(1, 1, 1)
|
||||
map.mapBufferRowRange(0, 1, 3)
|
||||
expect(map.screenRowRangeForBufferRow(0)).toEqual [0, 3]
|
||||
|
||||
describe "when the row range straddles existing regions", ->
|
||||
it "splits the straddled regions and places the new region between them", ->
|
||||
# filler region 0
|
||||
map.mapBufferRowRange(4, 7, 1) # region 1
|
||||
# filler region 2
|
||||
map.mapBufferRowRange(13, 15, 1) # region 3
|
||||
|
||||
# create region straddling region 0 and region 2
|
||||
map.mapBufferRowRange(2, 10, 1)
|
||||
|
||||
expect(map.regions[0]).toEqual(bufferRows: 2, screenRows: 2)
|
||||
expect(map.regions[1]).toEqual(bufferRows: 8, screenRows: 1)
|
||||
expect(map.regions[2]).toEqual(bufferRows: 3, screenRows: 8)
|
||||
expect(map.regions[3]).toEqual(bufferRows: 2, screenRows: 1)
|
||||
|
||||
it "merges adjacent isomorphic mappings", ->
|
||||
map.mapBufferRowRange(2, 4, 1)
|
||||
map.mapBufferRowRange(4, 5, 2)
|
||||
|
||||
map.mapBufferRowRange(1, 4, 3)
|
||||
expect(map.regions).toEqual [{bufferRows: 5, screenRows: 5}]
|
||||
|
||||
describe ".applyBufferDelta(startBufferRow, delta)", ->
|
||||
describe "when applying a positive delta", ->
|
||||
it "expands the region containing the given start row by the given delta", ->
|
||||
map.mapBufferRowRange(4, 8, 1)
|
||||
|
||||
map.applyBufferDelta(5, 4)
|
||||
|
||||
expect(map.regions[0]).toEqual(bufferRows: 4, screenRows: 4)
|
||||
expect(map.regions[1]).toEqual(bufferRows: 8, screenRows: 1)
|
||||
|
||||
describe "when applying a negative delta", ->
|
||||
it "shrinks regions starting at the start row until the entire delta is consumed", ->
|
||||
map.mapBufferRowRange(4, 8, 1)
|
||||
map.mapBufferRowRange(10, 14, 1)
|
||||
|
||||
map.applyBufferDelta(3, -6)
|
||||
|
||||
expect(map.regions[0]).toEqual(bufferRows: 3, screenRows: 4)
|
||||
expect(map.regions[1]).toEqual(bufferRows: 0, screenRows: 1)
|
||||
expect(map.regions[2]).toEqual(bufferRows: 1, screenRows: 2)
|
||||
expect(map.regions[3]).toEqual(bufferRows: 4, screenRows: 1)
|
||||
|
||||
describe ".applyScreenDelta(startScreenRow, delta)", ->
|
||||
describe "when applying a positive delta", ->
|
||||
it "can enlarge the screen side of existing regions", ->
|
||||
map.mapBufferRowRange(5, 6, 3) # wrapped line
|
||||
map.applyScreenDelta(5, 2) # wrap it twice more
|
||||
expect(map.screenRowRangeForBufferRow(5)).toEqual [5, 10]
|
||||
|
||||
describe "when applying a negative delta", ->
|
||||
it "can collapse the screen side of multiple regions to 0 until the entire delta has been applied", ->
|
||||
map.mapBufferRowRange(5, 10, 1) # inner fold 1
|
||||
map.mapBufferRowRange(11, 13, 1) # inner fold 2
|
||||
map.mapBufferRowRange(15, 20, 1) # inner fold 3
|
||||
map.mapBufferRowRange(22, 27, 1) # following fold
|
||||
|
||||
map.applyScreenDelta(6, -5)
|
||||
|
||||
expect(map.screenRowRangeForBufferRow(5)).toEqual [5, 6]
|
||||
expect(map.screenRowRangeForBufferRow(9)).toEqual [5, 6]
|
||||
expect(map.screenRowRangeForBufferRow(10)).toEqual [6, 6]
|
||||
expect(map.screenRowRangeForBufferRow(19)).toEqual [6, 6]
|
||||
expect(map.screenRowRangeForBufferRow(22)).toEqual [8, 9]
|
||||
expect(map.screenRowRangeForBufferRow(26)).toEqual [8, 9]
|
||||
|
||||
it "starts collapsing the first region at the start row, not before", ->
|
||||
map.mapBufferRowRange(5, 6, 4)
|
||||
map.mapBufferRowRange(11, 13, 1)
|
||||
|
||||
map.applyScreenDelta(7, -5)
|
||||
|
||||
expect(map.regions[0]).toEqual(bufferRows: 5, screenRows: 5)
|
||||
expect(map.regions[1]).toEqual(bufferRows: 1, screenRows: 2)
|
||||
expect(map.regions[2]).toEqual(bufferRows: 5, screenRows: 2)
|
||||
|
||||
it "does not throw an exception when applying a delta beyond the last region", ->
|
||||
map.mapBufferRowRange(5, 10, 1) # inner fold 1
|
||||
map.applyScreenDelta(15, 10)
|
||||
console.log map.inspect()
|
||||
|
@ -578,7 +578,7 @@ class DisplayBuffer extends Model
|
||||
logLines: (start=0, end=@getLastRow())->
|
||||
for row in [start..end]
|
||||
line = @lineForRow(row).text
|
||||
console.log row, line, line.length
|
||||
console.log row, @bufferRowForScreenRow(row), line, line.length
|
||||
|
||||
### Internal ###
|
||||
|
||||
@ -591,14 +591,12 @@ class DisplayBuffer extends Model
|
||||
startScreenRow = @rowMap.screenRowRangeForBufferRow(startBufferRow)[0]
|
||||
endScreenRow = @rowMap.screenRowRangeForBufferRow(endBufferRow - 1)[1]
|
||||
|
||||
@rowMap.applyBufferDelta(startBufferRow, bufferDelta)
|
||||
{screenLines, regions} = @buildScreenLines(startBufferRow, endBufferRow + bufferDelta)
|
||||
@screenLines[startScreenRow...endScreenRow] = screenLines
|
||||
screenDelta = screenLines.length - (endScreenRow - startScreenRow)
|
||||
|
||||
{ newScreenLines, newMappings } = @buildScreenLines(startBufferRow, endBufferRow + bufferDelta)
|
||||
@screenLines[startScreenRow...endScreenRow] = newScreenLines
|
||||
screenDelta = newScreenLines.length - (endScreenRow - startScreenRow)
|
||||
@rowMap.applyScreenDelta(startScreenRow, screenDelta)
|
||||
@rowMap.mapBufferRowRange(mapping...) for mapping in newMappings
|
||||
@findMaxLineLength(startScreenRow, endScreenRow, newScreenLines)
|
||||
@rowMap.spliceRegions(startBufferRow, endBufferRow - startBufferRow, regions)
|
||||
@findMaxLineLength(startScreenRow, endScreenRow, screenLines)
|
||||
|
||||
return if options.suppressChangeEvent
|
||||
|
||||
@ -615,26 +613,9 @@ class DisplayBuffer extends Model
|
||||
@emitChanged(changeEvent, options.refreshMarkers)
|
||||
|
||||
buildScreenLines: (startBufferRow, endBufferRow) ->
|
||||
newScreenLines = []
|
||||
newMappings = []
|
||||
pendingIsoMapping = null
|
||||
|
||||
pushNewMapping = (startBufferRow, endBufferRow, screenRows) ->
|
||||
if endBufferRow - startBufferRow == screenRows
|
||||
if pendingIsoMapping
|
||||
pendingIsoMapping[1] = endBufferRow
|
||||
else
|
||||
pendingIsoMapping = [startBufferRow, endBufferRow]
|
||||
else
|
||||
clearPendingIsoMapping()
|
||||
newMappings.push([startBufferRow, endBufferRow, screenRows])
|
||||
|
||||
clearPendingIsoMapping = ->
|
||||
if pendingIsoMapping
|
||||
[isoStart, isoEnd] = pendingIsoMapping
|
||||
pendingIsoMapping.push(isoEnd - isoStart)
|
||||
newMappings.push(pendingIsoMapping)
|
||||
pendingIsoMapping = null
|
||||
screenLines = []
|
||||
regions = []
|
||||
rectangularRegion = null
|
||||
|
||||
bufferRow = startBufferRow
|
||||
while bufferRow < endBufferRow
|
||||
@ -643,22 +624,39 @@ class DisplayBuffer extends Model
|
||||
if fold = @largestFoldStartingAtBufferRow(bufferRow)
|
||||
foldLine = tokenizedLine.copy()
|
||||
foldLine.fold = fold
|
||||
newScreenLines.push(foldLine)
|
||||
pushNewMapping(bufferRow, fold.getEndRow() + 1, 1)
|
||||
bufferRow = fold.getEndRow() + 1
|
||||
screenLines.push(foldLine)
|
||||
|
||||
if rectangularRegion?
|
||||
regions.push(rectangularRegion)
|
||||
rectangularRegion = null
|
||||
|
||||
foldedRowCount = fold.getBufferRowCount()
|
||||
regions.push(bufferRows: foldedRowCount, screenRows: 1)
|
||||
bufferRow += foldedRowCount
|
||||
else
|
||||
softWraps = 0
|
||||
while wrapScreenColumn = @findWrapColumn(tokenizedLine.text)
|
||||
[wrappedLine, tokenizedLine] = tokenizedLine.softWrapAt(wrapScreenColumn)
|
||||
|
||||
newScreenLines.push(wrappedLine)
|
||||
screenLines.push(wrappedLine)
|
||||
softWraps++
|
||||
newScreenLines.push(tokenizedLine)
|
||||
pushNewMapping(bufferRow, bufferRow + 1, softWraps + 1)
|
||||
bufferRow++
|
||||
clearPendingIsoMapping()
|
||||
screenLines.push(tokenizedLine)
|
||||
|
||||
{ newScreenLines, newMappings }
|
||||
if softWraps > 0
|
||||
if rectangularRegion?
|
||||
regions.push(rectangularRegion)
|
||||
rectangularRegion = null
|
||||
regions.push(bufferRows: 1, screenRows: softWraps + 1)
|
||||
else
|
||||
rectangularRegion ?= {bufferRows: 0, screenRows: 0}
|
||||
rectangularRegion.bufferRows++
|
||||
rectangularRegion.screenRows++
|
||||
|
||||
bufferRow++
|
||||
|
||||
if rectangularRegion?
|
||||
regions.push(rectangularRegion)
|
||||
|
||||
{screenLines, regions}
|
||||
|
||||
findMaxLineLength: (startScreenRow, endScreenRow, newScreenLines) ->
|
||||
if startScreenRow <= @longestScreenRow < endScreenRow
|
||||
|
@ -419,6 +419,8 @@ class Editor extends Model
|
||||
# {Delegates to: DisplayBuffer.bufferRowsForScreenRows}
|
||||
bufferRowsForScreenRows: (startRow, endRow) -> @displayBuffer.bufferRowsForScreenRows(startRow, endRow)
|
||||
|
||||
bufferRowForScreenRow: (row) -> @displayBuffer.bufferRowForScreenRow(row)
|
||||
|
||||
# {Delegates to: DisplayBuffer.scopesForBufferPosition}
|
||||
scopesForBufferPosition: (bufferPosition) -> @displayBuffer.scopesForBufferPosition(bufferPosition)
|
||||
|
||||
|
@ -1,179 +1,123 @@
|
||||
# Private: Maintains the canonical map between screen and buffer positions.
|
||||
{spliceWithArray} = require 'underscore-plus'
|
||||
|
||||
# Private: Used by the display buffer to map screen rows to buffer rows and
|
||||
# vice-versa. This mapping may not be 1:1 due to folds and soft-wraps. This
|
||||
# object maintains an array of regions, which contain `bufferRows` and
|
||||
# `screenRows` fields.
|
||||
#
|
||||
# Facilitates the mapping of screen rows to buffer rows and vice versa. All row
|
||||
# ranges dealt with by this class are end-row exclusive. For example, a fold of
|
||||
# rows 4 through 8 would be expressed as `mapBufferRowRange(4, 9, 1)`, which maps
|
||||
# the region from 4 to 9 in the buffer to a single screen row. Conversely, a
|
||||
# soft-wrapped screen line means there are multiple screen rows corresponding to
|
||||
# a single buffer row, as follows: `mapBufferRowRange(4, 5, 3)`. That says that
|
||||
# buffer row 4 maps to 3 rows on screen.
|
||||
# Rectangular Regions:
|
||||
# If a region has the same number of buffer rows and screen rows, it is referred
|
||||
# to as "rectangular", and represents one or more non-soft-wrapped, non-folded
|
||||
# lines.
|
||||
#
|
||||
# The RowMap revolves around the `@regions` array. Each region describes a number
|
||||
# of rows in both the screen and buffer coordinate spaces. So if you inserted a
|
||||
# single fold from 5-10, the regions array would look like this:
|
||||
#
|
||||
# ```
|
||||
# [{bufferRows: 5, screenRows: 5}, {bufferRows: 5, screenRows: 1}]
|
||||
# ```
|
||||
#
|
||||
# The first region expresses an iso-mapping, a region in which one buffer row
|
||||
# is equivalent to one screen row. The second region expresses the fold, with
|
||||
# 5 buffer rows mapping to a single screen row. Position translation functions
|
||||
# by traversing through these regions and summing the number of rows traversed
|
||||
# in both the screen and the buffer.
|
||||
# Trapezoidal Regions:
|
||||
# If a region has one buffer row and more than one screen row, it represents a
|
||||
# soft-wrapped line. If a region has one screen row and more than one buffer
|
||||
# row, it represents folded lines
|
||||
module.exports =
|
||||
class RowMap
|
||||
constructor: ->
|
||||
@regions = []
|
||||
|
||||
screenRowRangeForBufferRow: (targetBufferRow) ->
|
||||
{ region, screenRow, bufferRow } = @traverseToBufferRow(targetBufferRow)
|
||||
if region and region.bufferRows != region.screenRows # 1:n region
|
||||
[screenRow, screenRow + region.screenRows]
|
||||
else # 1:1 region
|
||||
screenRow += targetBufferRow - bufferRow
|
||||
[screenRow, screenRow + 1]
|
||||
# Public: Returns a copy of all the regions in the map
|
||||
getRegions: ->
|
||||
@regions.slice()
|
||||
|
||||
# This will return just the given buffer row if it is part of an iso region,
|
||||
# but if it is part of a fold it will return the range of the entire fold. This
|
||||
# helps the DisplayBuffer always start processing at the beginning of a fold
|
||||
# for changes that occur inside the fold.
|
||||
# Public: Returns and end-row-exclusive range of screen rows corresponding to
|
||||
# the given buffer row. If the buffer row is soft-wrapped, the range may span
|
||||
# multiple screen rows. Otherwise it will span a single screen row.
|
||||
screenRowRangeForBufferRow: (targetBufferRow) ->
|
||||
{region, bufferRows, screenRows} = @traverseToBufferRow(targetBufferRow)
|
||||
|
||||
if region? and region.bufferRows isnt region.screenRows
|
||||
[screenRows, screenRows + region.screenRows]
|
||||
else
|
||||
screenRows += targetBufferRow - bufferRows
|
||||
[screenRows, screenRows + 1]
|
||||
|
||||
# Public: Returns and end-row-exclusive range of buffer rows corresponding to
|
||||
# the given screen row. If the screen row is the first line of a folded range
|
||||
# of buffer rows, the range may span multiple buffer rows. Otherwise it will
|
||||
# span a single buffer row.
|
||||
bufferRowRangeForScreenRow: (targetScreenRow) ->
|
||||
{region, screenRows, bufferRows} = @traverseToScreenRow(targetScreenRow)
|
||||
if region? and region.bufferRows isnt region.screenRows
|
||||
[bufferRows, bufferRows + region.bufferRows]
|
||||
else
|
||||
bufferRows += targetScreenRow - screenRows
|
||||
[bufferRows, bufferRows + 1]
|
||||
|
||||
# Public: If the given buffer row is part of a folded row range, returns that
|
||||
# row range. Otherwise returns a range spanning only the given buffer row.
|
||||
bufferRowRangeForBufferRow: (targetBufferRow) ->
|
||||
{ region, screenRow, bufferRow } = @traverseToBufferRow(targetBufferRow)
|
||||
if region and region.bufferRows != region.screenRows # 1:n region
|
||||
[bufferRow, bufferRow + region.bufferRows]
|
||||
else # 1:1 region
|
||||
{region, bufferRows} = @traverseToBufferRow(targetBufferRow)
|
||||
if region? and region.bufferRows isnt region.screenRows
|
||||
[bufferRows, bufferRows + region.bufferRows]
|
||||
else
|
||||
[targetBufferRow, targetBufferRow + 1]
|
||||
|
||||
bufferRowRangeForScreenRow: (targetScreenRow) ->
|
||||
{ region, screenRow, bufferRow } = @traverseToScreenRow(targetScreenRow)
|
||||
if region and region.bufferRows != region.screenRows # 1:n region
|
||||
[bufferRow, bufferRow + region.bufferRows]
|
||||
else # 1:1 region
|
||||
bufferRow += targetScreenRow - screenRow
|
||||
[bufferRow, bufferRow + 1]
|
||||
# Public: Given a starting buffer row, the number of buffer rows to replace,
|
||||
# and an array of regions of shape {bufferRows: n, screenRows: m}, splices
|
||||
# the regions at the appropriate location in the map. This method is used by
|
||||
# display buffer to keep the map updated when the underlying buffer changes.
|
||||
spliceRegions: (startBufferRow, bufferRowCount, regions) ->
|
||||
endBufferRow = startBufferRow + bufferRowCount
|
||||
{index, bufferRows} = @traverseToBufferRow(startBufferRow)
|
||||
precedingRows = startBufferRow - bufferRows
|
||||
|
||||
# This method is used to create new regions, storing a mapping between a range
|
||||
# of buffer rows to a certain number of screen rows. It will never add or remove
|
||||
# rows in either coordinate space, meaning that it never changes the position
|
||||
# of subsequent regions. It will overwrite or split existing regions that overlap
|
||||
# with the region being stored however.
|
||||
mapBufferRowRange: (startBufferRow, endBufferRow, screenRows) ->
|
||||
{ index, bufferRow, screenRow } = @traverseToBufferRow(startBufferRow)
|
||||
count = 0
|
||||
while region = @regions[index + count]
|
||||
count++
|
||||
bufferRows += region.bufferRows
|
||||
if bufferRows >= endBufferRow
|
||||
followingRows = bufferRows - endBufferRow
|
||||
break
|
||||
|
||||
overlapStartIndex = index
|
||||
overlapStartBufferRow = bufferRow
|
||||
preRows = startBufferRow - overlapStartBufferRow
|
||||
endScreenRow = screenRow + preRows + screenRows
|
||||
overlapEndIndex = index
|
||||
overlapEndBufferRow = bufferRow
|
||||
overlapEndScreenRow = screenRow
|
||||
if precedingRows > 0
|
||||
regions.unshift({bufferRows: precedingRows, screenRows: precedingRows})
|
||||
|
||||
# determine regions that the new region overlaps. they will need replacement.
|
||||
while overlapEndIndex < @regions.length
|
||||
region = @regions[overlapEndIndex]
|
||||
overlapEndBufferRow += region.bufferRows
|
||||
overlapEndScreenRow += region.screenRows
|
||||
break if overlapEndBufferRow >= endBufferRow and overlapEndScreenRow >= endScreenRow
|
||||
overlapEndIndex++
|
||||
if followingRows > 0
|
||||
regions.push({bufferRows: followingRows, screenRows: followingRows})
|
||||
|
||||
# we will replace overlapStartIndex..overlapEndIndex with these regions
|
||||
newRegions = []
|
||||
|
||||
# if we straddle the first overlapping region, push a smaller region representing
|
||||
# the portion before the new region
|
||||
if preRows > 0
|
||||
newRegions.push(bufferRows: preRows, screenRows: preRows)
|
||||
|
||||
# push the new region
|
||||
newRegions.push(bufferRows: endBufferRow - startBufferRow, screenRows: screenRows)
|
||||
|
||||
# if we straddle the last overlapping region, push a smaller region representing
|
||||
# the portion after the new region
|
||||
if overlapEndBufferRow > endBufferRow
|
||||
newRegions.push(bufferRows: overlapEndBufferRow - endBufferRow, screenRows: overlapEndScreenRow - endScreenRow)
|
||||
|
||||
@regions[overlapStartIndex..overlapEndIndex] = newRegions
|
||||
@mergeIsomorphicRegions(Math.max(0, overlapStartIndex - 1), Math.min(@regions.length - 1, overlapEndIndex + 1))
|
||||
|
||||
mergeIsomorphicRegions: (startIndex, endIndex) ->
|
||||
return if startIndex == endIndex
|
||||
|
||||
region = @regions[startIndex]
|
||||
nextRegion = @regions[startIndex + 1]
|
||||
if region.bufferRows == region.screenRows and nextRegion.bufferRows == nextRegion.screenRows
|
||||
@regions[startIndex..startIndex + 1] =
|
||||
bufferRows: region.bufferRows + nextRegion.bufferRows
|
||||
screenRows: region.screenRows + nextRegion.screenRows
|
||||
@mergeIsomorphicRegions(startIndex, endIndex - 1)
|
||||
else
|
||||
@mergeIsomorphicRegions(startIndex + 1, endIndex)
|
||||
|
||||
# This method records insertion or removal of rows in the buffer, adjusting the
|
||||
# buffer dimension of regions following the start row accordingly.
|
||||
applyBufferDelta: (startBufferRow, delta) ->
|
||||
return if delta is 0
|
||||
{ index, bufferRow } = @traverseToBufferRow(startBufferRow)
|
||||
if delta > 0 and index < @regions.length
|
||||
{ bufferRows, screenRows } = @regions[index]
|
||||
bufferRows += delta
|
||||
@regions[index] = { bufferRows, screenRows }
|
||||
else
|
||||
delta = -delta
|
||||
while delta > 0 and index < @regions.length
|
||||
{ bufferRows, screenRows } = @regions[index]
|
||||
regionStartBufferRow = bufferRow
|
||||
regionEndBufferRow = bufferRow + bufferRows
|
||||
maxDelta = regionEndBufferRow - Math.max(regionStartBufferRow, startBufferRow)
|
||||
regionDelta = Math.min(delta, maxDelta)
|
||||
bufferRows -= regionDelta
|
||||
@regions[index] = { bufferRows, screenRows }
|
||||
delta -= regionDelta
|
||||
bufferRow += bufferRows
|
||||
index++
|
||||
|
||||
# This method records insertion or removal of rows on the screen, adjusting the
|
||||
# screen dimension of regions following the start row accordingly.
|
||||
applyScreenDelta: (startScreenRow, delta) ->
|
||||
return if delta is 0
|
||||
{ index, screenRow } = @traverseToScreenRow(startScreenRow)
|
||||
if delta > 0 and index < @regions.length
|
||||
{ bufferRows, screenRows } = @regions[index]
|
||||
screenRows += delta
|
||||
@regions[index] = { bufferRows, screenRows }
|
||||
else
|
||||
delta = -delta
|
||||
while delta > 0 and index < @regions.length
|
||||
{ bufferRows, screenRows } = @regions[index]
|
||||
regionStartScreenRow = screenRow
|
||||
regionEndScreenRow = screenRow + screenRows
|
||||
maxDelta = regionEndScreenRow - Math.max(regionStartScreenRow, startScreenRow)
|
||||
regionDelta = Math.min(delta, maxDelta)
|
||||
screenRows -= regionDelta
|
||||
@regions[index] = { bufferRows, screenRows }
|
||||
delta -= regionDelta
|
||||
screenRow += screenRows
|
||||
index++
|
||||
spliceWithArray(@regions, index, count, regions)
|
||||
@mergeAdjacentRectangularRegions(index - 1, index + regions.length)
|
||||
|
||||
# Private:
|
||||
traverseToBufferRow: (targetBufferRow) ->
|
||||
bufferRow = 0
|
||||
screenRow = 0
|
||||
bufferRows = 0
|
||||
screenRows = 0
|
||||
for region, index in @regions
|
||||
if (bufferRow + region.bufferRows) > targetBufferRow or region.bufferRows == 0 and bufferRow == targetBufferRow
|
||||
return { region, index, screenRow, bufferRow }
|
||||
bufferRow += region.bufferRows
|
||||
screenRow += region.screenRows
|
||||
{ index, screenRow, bufferRow }
|
||||
if (bufferRows + region.bufferRows) > targetBufferRow
|
||||
return {region, index, screenRows, bufferRows}
|
||||
bufferRows += region.bufferRows
|
||||
screenRows += region.screenRows
|
||||
{index, screenRows, bufferRows}
|
||||
|
||||
# Private:
|
||||
traverseToScreenRow: (targetScreenRow) ->
|
||||
bufferRow = 0
|
||||
screenRow = 0
|
||||
bufferRows = 0
|
||||
screenRows = 0
|
||||
for region, index in @regions
|
||||
if (screenRow + region.screenRows) > targetScreenRow
|
||||
return { region, index, screenRow, bufferRow }
|
||||
bufferRow += region.bufferRows
|
||||
screenRow += region.screenRows
|
||||
{ index, screenRow, bufferRow }
|
||||
if (screenRows + region.screenRows) > targetScreenRow
|
||||
return {region, index, screenRows, bufferRows}
|
||||
bufferRows += region.bufferRows
|
||||
screenRows += region.screenRows
|
||||
{index, screenRows, bufferRows}
|
||||
|
||||
# Private:
|
||||
mergeAdjacentRectangularRegions: (startIndex, endIndex) ->
|
||||
for index in [endIndex..startIndex]
|
||||
if 0 < index < @regions.length
|
||||
leftRegion = @regions[index - 1]
|
||||
rightRegion = @regions[index]
|
||||
leftIsRectangular = leftRegion.bufferRows is leftRegion.screenRows
|
||||
rightIsRectangular = rightRegion.bufferRows is rightRegion.screenRows
|
||||
if leftIsRectangular and rightIsRectangular
|
||||
@regions.splice index - 1, 2,
|
||||
bufferRows: leftRegion.bufferRows + rightRegion.bufferRows
|
||||
screenRows: leftRegion.screenRows + rightRegion.screenRows
|
||||
|
||||
# Public: Returns an array of strings describing the map's regions.
|
||||
inspect: ->
|
||||
@regions.map(({screenRows, bufferRows}) -> "#{screenRows}:#{bufferRows}").join(', ')
|
||||
for {bufferRows, screenRows} in @regions
|
||||
"#{bufferRows}:#{screenRows}"
|
||||
|
Loading…
Reference in New Issue
Block a user