diff --git a/src/Buffer.test.ts b/src/Buffer.test.ts index 0546dfe809..1ef2e92c66 100644 --- a/src/Buffer.test.ts +++ b/src/Buffer.test.ts @@ -108,12 +108,12 @@ describe('Buffer', () => { describe('resize', () => { describe('column size is reduced', () => { - it('should not trim the data in the buffer', () => { + it('should trim the data in the buffer', () => { buffer.fillViewportRows(); buffer.resize(INIT_COLS / 2, INIT_ROWS); assert.equal(buffer.lines.length, INIT_ROWS); for (let i = 0; i < INIT_ROWS; i++) { - assert.equal(buffer.lines.get(i).length, INIT_COLS); + assert.equal(buffer.lines.get(i).length, INIT_COLS / 2); } }); }); @@ -233,6 +233,761 @@ describe('Buffer', () => { } }); }); + + describe('reflow', () => { + it('should not wrap empty lines', () => { + buffer.fillViewportRows(); + assert.equal(buffer.lines.length, INIT_ROWS); + buffer.resize(INIT_COLS - 5, INIT_ROWS); + assert.equal(buffer.lines.length, INIT_ROWS); + }); + it('should shrink row length', () => { + buffer.fillViewportRows(); + buffer.resize(5, 10); + assert.equal(buffer.lines.length, 10); + assert.equal(buffer.lines.get(0).length, 5); + assert.equal(buffer.lines.get(1).length, 5); + assert.equal(buffer.lines.get(2).length, 5); + assert.equal(buffer.lines.get(3).length, 5); + assert.equal(buffer.lines.get(4).length, 5); + assert.equal(buffer.lines.get(5).length, 5); + assert.equal(buffer.lines.get(6).length, 5); + assert.equal(buffer.lines.get(7).length, 5); + assert.equal(buffer.lines.get(8).length, 5); + assert.equal(buffer.lines.get(9).length, 5); + }); + it('should wrap and unwrap lines', () => { + buffer.fillViewportRows(); + buffer.resize(5, 10); + const firstLine = buffer.lines.get(0); + for (let i = 0; i < 5; i++) { + const code = 'a'.charCodeAt(0) + i; + const char = String.fromCharCode(code); + firstLine.set(i, [null, char, 1, code]); + } + assert.equal(buffer.lines.get(0).length, 5); + assert.equal(buffer.lines.get(0).translateToString(), 'abcde'); + buffer.resize(1, 10); + assert.equal(buffer.lines.length, 10); + assert.equal(buffer.lines.get(0).translateToString(), 'a'); + assert.equal(buffer.lines.get(1).translateToString(), 'b'); + assert.equal(buffer.lines.get(2).translateToString(), 'c'); + assert.equal(buffer.lines.get(3).translateToString(), 'd'); + assert.equal(buffer.lines.get(4).translateToString(), 'e'); + assert.equal(buffer.lines.get(5).translateToString(), ' '); + assert.equal(buffer.lines.get(6).translateToString(), ' '); + assert.equal(buffer.lines.get(7).translateToString(), ' '); + assert.equal(buffer.lines.get(8).translateToString(), ' '); + assert.equal(buffer.lines.get(9).translateToString(), ' '); + buffer.resize(5, 10); + assert.equal(buffer.lines.length, 10); + assert.equal(buffer.lines.get(0).translateToString(), 'abcde'); + assert.equal(buffer.lines.get(1).translateToString(), ' '); + assert.equal(buffer.lines.get(2).translateToString(), ' '); + assert.equal(buffer.lines.get(3).translateToString(), ' '); + assert.equal(buffer.lines.get(4).translateToString(), ' '); + assert.equal(buffer.lines.get(5).translateToString(), ' '); + assert.equal(buffer.lines.get(6).translateToString(), ' '); + assert.equal(buffer.lines.get(7).translateToString(), ' '); + assert.equal(buffer.lines.get(8).translateToString(), ' '); + assert.equal(buffer.lines.get(9).translateToString(), ' '); + }); + it('should discard parts of wrapped lines that go out of the scrollback', () => { + buffer.fillViewportRows(); + terminal.options.scrollback = 1; + buffer.resize(10, 5); + const lastLine = buffer.lines.get(4); + for (let i = 0; i < 10; i++) { + const code = 'a'.charCodeAt(0) + i; + const char = String.fromCharCode(code); + lastLine.set(i, [null, char, 1, code]); + } + assert.equal(buffer.lines.length, 5); + buffer.y = 4; + buffer.resize(2, 5); + assert.equal(buffer.y, 4); + assert.equal(buffer.ybase, 1); + assert.equal(buffer.lines.length, 6); + assert.equal(buffer.lines.get(0).translateToString(), ' '); + assert.equal(buffer.lines.get(1).translateToString(), 'ab'); + assert.equal(buffer.lines.get(2).translateToString(), 'cd'); + assert.equal(buffer.lines.get(3).translateToString(), 'ef'); + assert.equal(buffer.lines.get(4).translateToString(), 'gh'); + assert.equal(buffer.lines.get(5).translateToString(), 'ij'); + buffer.resize(1, 5); + assert.equal(buffer.y, 4); + assert.equal(buffer.ybase, 1); + assert.equal(buffer.lines.length, 6); + assert.equal(buffer.lines.get(0).translateToString(), 'e'); + assert.equal(buffer.lines.get(1).translateToString(), 'f'); + assert.equal(buffer.lines.get(2).translateToString(), 'g'); + assert.equal(buffer.lines.get(3).translateToString(), 'h'); + assert.equal(buffer.lines.get(4).translateToString(), 'i'); + assert.equal(buffer.lines.get(5).translateToString(), 'j'); + buffer.resize(10, 5); + assert.equal(buffer.y, 0); + assert.equal(buffer.ybase, 0); + assert.equal(buffer.lines.length, 5); + assert.equal(buffer.lines.get(0).translateToString(), 'efghij '); + assert.equal(buffer.lines.get(1).translateToString(), ' '); + assert.equal(buffer.lines.get(2).translateToString(), ' '); + assert.equal(buffer.lines.get(3).translateToString(), ' '); + assert.equal(buffer.lines.get(4).translateToString(), ' '); + }); + it('should remove the correct amount of rows when reflowing larger', () => { + // This is a regression test to ensure that successive wrapped lines that are getting + // 3+ lines removed on a reflow actually remove the right lines + buffer.fillViewportRows(); + buffer.resize(10, 10); + const firstLine = buffer.lines.get(0); + const secondLine = buffer.lines.get(1); + for (let i = 0; i < 10; i++) { + const code = 'a'.charCodeAt(0) + i; + const char = String.fromCharCode(code); + firstLine.set(i, [null, char, 1, code]); + } + for (let i = 0; i < 10; i++) { + const code = '0'.charCodeAt(0) + i; + const char = String.fromCharCode(code); + secondLine.set(i, [null, char, 1, code]); + } + assert.equal(buffer.lines.length, 10); + assert.equal(buffer.lines.get(0).translateToString(), 'abcdefghij'); + assert.equal(buffer.lines.get(1).translateToString(), '0123456789'); + for (let i = 2; i < 10; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + buffer.resize(2, 10); + assert.equal(buffer.ybase, 0); + assert.equal(buffer.lines.length, 10); + assert.equal(buffer.lines.get(0).translateToString(), 'ab'); + assert.equal(buffer.lines.get(1).translateToString(), 'cd'); + assert.equal(buffer.lines.get(2).translateToString(), 'ef'); + assert.equal(buffer.lines.get(3).translateToString(), 'gh'); + assert.equal(buffer.lines.get(4).translateToString(), 'ij'); + assert.equal(buffer.lines.get(5).translateToString(), '01'); + assert.equal(buffer.lines.get(6).translateToString(), '23'); + assert.equal(buffer.lines.get(7).translateToString(), '45'); + assert.equal(buffer.lines.get(8).translateToString(), '67'); + assert.equal(buffer.lines.get(9).translateToString(), '89'); + buffer.resize(10, 10); + assert.equal(buffer.lines.get(0).translateToString(), 'abcdefghij'); + assert.equal(buffer.lines.get(1).translateToString(), '0123456789'); + for (let i = 2; i < 10; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + }); + it('should transfer combined char data over to reflowed lines', () => { + buffer.fillViewportRows(); + buffer.resize(4, 2); + const firstLine = buffer.lines.get(0); + firstLine.set(0, [ null, 'a', 1, 'a'.charCodeAt(0) ]); + firstLine.set(1, [ null, 'b', 1, 'b'.charCodeAt(0) ]); + firstLine.set(2, [ null, 'c', 1, 'c'.charCodeAt(0) ]); + firstLine.set(3, [ null, '😁', 1, '😁'.charCodeAt(0) ]); + assert.equal(buffer.lines.length, 2); + assert.equal(buffer.lines.get(0).translateToString(), 'abc😁'); + assert.equal(buffer.lines.get(1).translateToString(), ' '); + buffer.resize(2, 2); + assert.equal(buffer.lines.get(0).translateToString(), 'ab'); + assert.equal(buffer.lines.get(1).translateToString(), 'c😁'); + }); + it('should adjust markers when reflowing', () => { + buffer.fillViewportRows(); + buffer.resize(10, 15); + for (let i = 0; i < 10; i++) { + const code = 'a'.charCodeAt(0) + i; + const char = String.fromCharCode(code); + buffer.lines.get(0).set(i, [null, char, 1, code]); + } + for (let i = 0; i < 10; i++) { + const code = '0'.charCodeAt(0) + i; + const char = String.fromCharCode(code); + buffer.lines.get(1).set(i, [null, char, 1, code]); + } + for (let i = 0; i < 10; i++) { + const code = 'k'.charCodeAt(0) + i; + const char = String.fromCharCode(code); + buffer.lines.get(2).set(i, [null, char, 1, code]); + } + // Buffer: + // abcdefghij + // 0123456789 + // abcdefghij + const firstMarker = buffer.addMarker(0); + const secondMarker = buffer.addMarker(1); + const thirdMarker = buffer.addMarker(2); + assert.equal(buffer.lines.get(0).translateToString(), 'abcdefghij'); + assert.equal(buffer.lines.get(1).translateToString(), '0123456789'); + assert.equal(buffer.lines.get(2).translateToString(), 'klmnopqrst'); + assert.equal(firstMarker.line, 0); + assert.equal(secondMarker.line, 1); + assert.equal(thirdMarker.line, 2); + buffer.resize(2, 15); + assert.equal(buffer.lines.get(0).translateToString(), 'ab'); + assert.equal(buffer.lines.get(1).translateToString(), 'cd'); + assert.equal(buffer.lines.get(2).translateToString(), 'ef'); + assert.equal(buffer.lines.get(3).translateToString(), 'gh'); + assert.equal(buffer.lines.get(4).translateToString(), 'ij'); + assert.equal(buffer.lines.get(5).translateToString(), '01'); + assert.equal(buffer.lines.get(6).translateToString(), '23'); + assert.equal(buffer.lines.get(7).translateToString(), '45'); + assert.equal(buffer.lines.get(8).translateToString(), '67'); + assert.equal(buffer.lines.get(9).translateToString(), '89'); + assert.equal(buffer.lines.get(10).translateToString(), 'kl'); + assert.equal(buffer.lines.get(11).translateToString(), 'mn'); + assert.equal(buffer.lines.get(12).translateToString(), 'op'); + assert.equal(buffer.lines.get(13).translateToString(), 'qr'); + assert.equal(buffer.lines.get(14).translateToString(), 'st'); + assert.equal(firstMarker.line, 0, 'first marker should remain unchanged'); + assert.equal(secondMarker.line, 5, 'second marker should be shifted since the first line wrapped'); + assert.equal(thirdMarker.line, 10, 'third marker should be shifted since the first and second lines wrapped'); + buffer.resize(10, 15); + assert.equal(buffer.lines.get(0).translateToString(), 'abcdefghij'); + assert.equal(buffer.lines.get(1).translateToString(), '0123456789'); + assert.equal(buffer.lines.get(2).translateToString(), 'klmnopqrst'); + assert.equal(firstMarker.line, 0, 'first marker should remain unchanged'); + assert.equal(secondMarker.line, 1, 'second marker should be restored to it\'s original line'); + assert.equal(thirdMarker.line, 2, 'third marker should be restored to it\'s original line'); + assert.equal(firstMarker.isDisposed, false); + assert.equal(secondMarker.isDisposed, false); + assert.equal(thirdMarker.isDisposed, false); + }); + it('should dispose markers whose rows are trimmed during a reflow', () => { + buffer.fillViewportRows(); + terminal.options.scrollback = 1; + buffer.resize(10, 10); + for (let i = 0; i < 10; i++) { + const code = 'a'.charCodeAt(0) + i; + const char = String.fromCharCode(code); + buffer.lines.get(0).set(i, [null, char, 1, code]); + } + for (let i = 0; i < 10; i++) { + const code = '0'.charCodeAt(0) + i; + const char = String.fromCharCode(code); + buffer.lines.get(1).set(i, [null, char, 1, code]); + } + for (let i = 0; i < 10; i++) { + const code = 'k'.charCodeAt(0) + i; + const char = String.fromCharCode(code); + buffer.lines.get(2).set(i, [null, char, 1, code]); + } + // Buffer: + // abcdefghij + // 0123456789 + // abcdefghij + const firstMarker = buffer.addMarker(0); + const secondMarker = buffer.addMarker(1); + const thirdMarker = buffer.addMarker(2); + buffer.y = 2; + assert.equal(buffer.lines.get(0).translateToString(), 'abcdefghij'); + assert.equal(buffer.lines.get(1).translateToString(), '0123456789'); + assert.equal(buffer.lines.get(2).translateToString(), 'klmnopqrst'); + assert.equal(firstMarker.line, 0); + assert.equal(secondMarker.line, 1); + assert.equal(thirdMarker.line, 2); + buffer.resize(2, 10); + assert.equal(buffer.lines.get(0).translateToString(), 'ij'); + assert.equal(buffer.lines.get(1).translateToString(), '01'); + assert.equal(buffer.lines.get(2).translateToString(), '23'); + assert.equal(buffer.lines.get(3).translateToString(), '45'); + assert.equal(buffer.lines.get(4).translateToString(), '67'); + assert.equal(buffer.lines.get(5).translateToString(), '89'); + assert.equal(buffer.lines.get(6).translateToString(), 'kl'); + assert.equal(buffer.lines.get(7).translateToString(), 'mn'); + assert.equal(buffer.lines.get(8).translateToString(), 'op'); + assert.equal(buffer.lines.get(9).translateToString(), 'qr'); + assert.equal(buffer.lines.get(10).translateToString(), 'st'); + assert.equal(secondMarker.line, 1, 'second marker should remain the same as it was shifted 4 and trimmed 4'); + assert.equal(thirdMarker.line, 6, 'third marker should be shifted since the first and second lines wrapped'); + assert.equal(firstMarker.isDisposed, true, 'first marker was trimmed'); + assert.equal(secondMarker.isDisposed, false); + assert.equal(thirdMarker.isDisposed, false); + buffer.resize(10, 10); + assert.equal(buffer.lines.get(0).translateToString(), 'ij '); + assert.equal(buffer.lines.get(1).translateToString(), '0123456789'); + assert.equal(buffer.lines.get(2).translateToString(), 'klmnopqrst'); + assert.equal(secondMarker.line, 1, 'second marker should be restored'); + assert.equal(thirdMarker.line, 2, 'third marker should be restored'); + }); + it('should wrap wide characters correctly when reflowing larger', () => { + buffer.fillViewportRows(); + buffer.resize(12, 10); + for (let i = 0; i < 12; i += 4) { + buffer.lines.get(0).set(i, [null, '汉', 2, '汉'.charCodeAt(0)]); + buffer.lines.get(1).set(i, [null, '汉', 2, '汉'.charCodeAt(0)]); + } + for (let i = 2; i < 12; i += 4) { + buffer.lines.get(0).set(i, [null, '语', 2, '语'.charCodeAt(0)]); + buffer.lines.get(1).set(i, [null, '语', 2, '语'.charCodeAt(0)]); + } + for (let i = 1; i < 12; i += 2) { + buffer.lines.get(0).set(i, [null, '', 0, undefined]); + buffer.lines.get(1).set(i, [null, '', 0, undefined]); + } + buffer.lines.get(1).isWrapped = true; + // Buffer: + // 汉语汉语汉语 (wrapped) + // 汉语汉语汉语 + assert.equal(buffer.lines.get(0).translateToString(true), '汉语汉语汉语'); + assert.equal(buffer.lines.get(1).translateToString(true), '汉语汉语汉语'); + buffer.resize(13, 10); + assert.equal(buffer.ybase, 0); + assert.equal(buffer.lines.length, 10); + assert.equal(buffer.lines.get(0).translateToString(true), '汉语汉语汉语'); + assert.equal(buffer.lines.get(0).translateToString(false), '汉语汉语汉语 '); + assert.equal(buffer.lines.get(1).translateToString(true), '汉语汉语汉语'); + assert.equal(buffer.lines.get(1).translateToString(false), '汉语汉语汉语 '); + buffer.resize(14, 10); + assert.equal(buffer.lines.get(0).translateToString(true), '汉语汉语汉语汉'); + assert.equal(buffer.lines.get(0).translateToString(false), '汉语汉语汉语汉'); + assert.equal(buffer.lines.get(1).translateToString(true), '语汉语汉语'); + assert.equal(buffer.lines.get(1).translateToString(false), '语汉语汉语 '); + }); + it('should wrap wide characters correctly when reflowing smaller', () => { + buffer.fillViewportRows(); + buffer.resize(12, 10); + for (let i = 0; i < 12; i += 4) { + buffer.lines.get(0).set(i, [null, '汉', 2, '汉'.charCodeAt(0)]); + buffer.lines.get(1).set(i, [null, '汉', 2, '汉'.charCodeAt(0)]); + } + for (let i = 2; i < 12; i += 4) { + buffer.lines.get(0).set(i, [null, '语', 2, '语'.charCodeAt(0)]); + buffer.lines.get(1).set(i, [null, '语', 2, '语'.charCodeAt(0)]); + } + for (let i = 1; i < 12; i += 2) { + buffer.lines.get(0).set(i, [null, '', 0, undefined]); + buffer.lines.get(1).set(i, [null, '', 0, undefined]); + } + buffer.lines.get(1).isWrapped = true; + // Buffer: + // 汉语汉语汉语 (wrapped) + // 汉语汉语汉语 + assert.equal(buffer.lines.get(0).translateToString(true), '汉语汉语汉语'); + assert.equal(buffer.lines.get(1).translateToString(true), '汉语汉语汉语'); + buffer.resize(11, 10); + assert.equal(buffer.ybase, 0); + assert.equal(buffer.lines.length, 10); + assert.equal(buffer.lines.get(0).translateToString(true), '汉语汉语汉'); + assert.equal(buffer.lines.get(1).translateToString(true), '语汉语汉语'); + assert.equal(buffer.lines.get(2).translateToString(true), '汉语'); + buffer.resize(10, 10); + assert.equal(buffer.lines.get(0).translateToString(true), '汉语汉语汉'); + assert.equal(buffer.lines.get(1).translateToString(true), '语汉语汉语'); + assert.equal(buffer.lines.get(2).translateToString(true), '汉语'); + buffer.resize(9, 10); + assert.equal(buffer.lines.get(0).translateToString(true), '汉语汉语'); + assert.equal(buffer.lines.get(1).translateToString(true), '汉语汉语'); + assert.equal(buffer.lines.get(2).translateToString(true), '汉语汉语'); + buffer.resize(8, 10); + assert.equal(buffer.lines.get(0).translateToString(true), '汉语汉语'); + assert.equal(buffer.lines.get(1).translateToString(true), '汉语汉语'); + assert.equal(buffer.lines.get(2).translateToString(true), '汉语汉语'); + buffer.resize(7, 10); + assert.equal(buffer.lines.get(0).translateToString(true), '汉语汉'); + assert.equal(buffer.lines.get(1).translateToString(true), '语汉语'); + assert.equal(buffer.lines.get(2).translateToString(true), '汉语汉'); + assert.equal(buffer.lines.get(3).translateToString(true), '语汉语'); + buffer.resize(6, 10); + assert.equal(buffer.lines.get(0).translateToString(true), '汉语汉'); + assert.equal(buffer.lines.get(1).translateToString(true), '语汉语'); + assert.equal(buffer.lines.get(2).translateToString(true), '汉语汉'); + assert.equal(buffer.lines.get(3).translateToString(true), '语汉语'); + }); + + describe('reflowLarger cases', () => { + beforeEach(() => { + // Setup buffer state: + // 'ab' + // 'cd' (wrapped) + // 'ef' + // 'gh' (wrapped) + // 'ij' + // 'kl' (wrapped) + // ' ' + // ' ' + // ' ' + // ' ' + buffer.fillViewportRows(); + buffer.resize(2, 10); + buffer.lines.get(0).set(0, [null, 'a', 1, 'a'.charCodeAt(0)]); + buffer.lines.get(0).set(1, [null, 'b', 1, 'b'.charCodeAt(0)]); + buffer.lines.get(1).set(0, [null, 'c', 1, 'c'.charCodeAt(0)]); + buffer.lines.get(1).set(1, [null, 'd', 1, 'd'.charCodeAt(0)]); + buffer.lines.get(1).isWrapped = true; + buffer.lines.get(2).set(0, [null, 'e', 1, 'e'.charCodeAt(0)]); + buffer.lines.get(2).set(1, [null, 'f', 1, 'f'.charCodeAt(0)]); + buffer.lines.get(3).set(0, [null, 'g', 1, 'g'.charCodeAt(0)]); + buffer.lines.get(3).set(1, [null, 'h', 1, 'h'.charCodeAt(0)]); + buffer.lines.get(3).isWrapped = true; + buffer.lines.get(4).set(0, [null, 'i', 1, 'i'.charCodeAt(0)]); + buffer.lines.get(4).set(1, [null, 'j', 1, 'j'.charCodeAt(0)]); + buffer.lines.get(5).set(0, [null, 'k', 1, 'k'.charCodeAt(0)]); + buffer.lines.get(5).set(1, [null, 'l', 1, 'l'.charCodeAt(0)]); + buffer.lines.get(5).isWrapped = true; + }); + describe('viewport not yet filled', () => { + it('should move the cursor up and add empty lines', () => { + buffer.y = 6; + buffer.resize(4, 10); + assert.equal(buffer.y, 3); + assert.equal(buffer.ydisp, 0); + assert.equal(buffer.ybase, 0); + assert.equal(buffer.lines.length, 10); + assert.equal(buffer.lines.get(0).translateToString(), 'abcd'); + assert.equal(buffer.lines.get(1).translateToString(), 'efgh'); + assert.equal(buffer.lines.get(2).translateToString(), 'ijkl'); + for (let i = 3; i < 10; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + const wrappedLines: number[] = []; + for (let i = 0; i < buffer.lines.length; i++) { + assert.equal(buffer.lines.get(i).isWrapped, wrappedLines.indexOf(i) !== -1, `line ${i} isWrapped must equal ${wrappedLines.indexOf(i) !== -1}`); + } + }); + }); + describe('viewport filled, scrollback remaining', () => { + beforeEach(() => { + buffer.y = 9; + }); + describe('ybase === 0', () => { + it('should move the cursor up and add empty lines', () => { + buffer.resize(4, 10); + assert.equal(buffer.y, 6); + assert.equal(buffer.ydisp, 0); + assert.equal(buffer.ybase, 0); + assert.equal(buffer.lines.length, 10); + assert.equal(buffer.lines.get(0).translateToString(), 'abcd'); + assert.equal(buffer.lines.get(1).translateToString(), 'efgh'); + assert.equal(buffer.lines.get(2).translateToString(), 'ijkl'); + for (let i = 3; i < 10; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + const wrappedLines: number[] = []; + for (let i = 0; i < buffer.lines.length; i++) { + assert.equal(buffer.lines.get(i).isWrapped, wrappedLines.indexOf(i) !== -1, `line ${i} isWrapped must equal ${wrappedLines.indexOf(i) !== -1}`); + } + }); + }); + describe('ybase !== 0', () => { + beforeEach(() => { + // Add 10 empty rows to start + for (let i = 0; i < 10; i++) { + buffer.lines.splice(0, 0, buffer.getBlankLine(DEFAULT_ATTR)); + } + buffer.ybase = 10; + }); + describe('&& ydisp === ybase', () => { + it('should adjust the viewport and keep ydisp = ybase', () => { + buffer.ydisp = 10; + buffer.resize(4, 10); + assert.equal(buffer.y, 9); + assert.equal(buffer.ydisp, 7); + assert.equal(buffer.ybase, 7); + assert.equal(buffer.lines.length, 17); + for (let i = 0; i < 10; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + assert.equal(buffer.lines.get(10).translateToString(), 'abcd'); + assert.equal(buffer.lines.get(11).translateToString(), 'efgh'); + assert.equal(buffer.lines.get(12).translateToString(), 'ijkl'); + for (let i = 13; i < 17; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + const wrappedLines: number[] = []; + for (let i = 0; i < buffer.lines.length; i++) { + assert.equal(buffer.lines.get(i).isWrapped, wrappedLines.indexOf(i) !== -1, `line ${i} isWrapped must equal ${wrappedLines.indexOf(i) !== -1}`); + } + }); + }); + describe('&& ydisp !== ybase', () => { + it('should keep ydisp at the same value', () => { + buffer.ydisp = 5; + buffer.resize(4, 10); + assert.equal(buffer.y, 9); + assert.equal(buffer.ydisp, 5); + assert.equal(buffer.ybase, 7); + assert.equal(buffer.lines.length, 17); + for (let i = 0; i < 10; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + assert.equal(buffer.lines.get(10).translateToString(), 'abcd'); + assert.equal(buffer.lines.get(11).translateToString(), 'efgh'); + assert.equal(buffer.lines.get(12).translateToString(), 'ijkl'); + for (let i = 13; i < 17; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + const wrappedLines: number[] = []; + for (let i = 0; i < buffer.lines.length; i++) { + assert.equal(buffer.lines.get(i).isWrapped, wrappedLines.indexOf(i) !== -1, `line ${i} isWrapped must equal ${wrappedLines.indexOf(i) !== -1}`); + } + }); + }); + }); + }); + describe('viewport filled, no scrollback remaining', () => { + // ybase === 0 doesn't make sense here as scrollback=0 isn't really supported + describe('ybase !== 0', () => { + beforeEach(() => { + terminal.options.scrollback = 10; + // Add 10 empty rows to start + for (let i = 0; i < 10; i++) { + buffer.lines.splice(0, 0, buffer.getBlankLine(DEFAULT_ATTR)); + } + buffer.y = 9; + buffer.ybase = 10; + }); + describe('&& ydisp === ybase', () => { + it('should trim lines and keep ydisp = ybase', () => { + buffer.ydisp = 10; + buffer.resize(4, 10); + assert.equal(buffer.y, 9); + assert.equal(buffer.ydisp, 7); + assert.equal(buffer.ybase, 7); + assert.equal(buffer.lines.length, 17); + for (let i = 0; i < 10; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + assert.equal(buffer.lines.get(10).translateToString(), 'abcd'); + assert.equal(buffer.lines.get(11).translateToString(), 'efgh'); + assert.equal(buffer.lines.get(12).translateToString(), 'ijkl'); + for (let i = 13; i < 17; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + const wrappedLines: number[] = []; + for (let i = 0; i < buffer.lines.length; i++) { + assert.equal(buffer.lines.get(i).isWrapped, wrappedLines.indexOf(i) !== -1, `line ${i} isWrapped must equal ${wrappedLines.indexOf(i) !== -1}`); + } + }); + }); + describe('&& ydisp !== ybase', () => { + it('should trim lines and not change ydisp', () => { + buffer.ydisp = 5; + buffer.resize(4, 10); + assert.equal(buffer.y, 9); + assert.equal(buffer.ydisp, 5); + assert.equal(buffer.ybase, 7); + assert.equal(buffer.lines.length, 17); + for (let i = 0; i < 10; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + assert.equal(buffer.lines.get(10).translateToString(), 'abcd'); + assert.equal(buffer.lines.get(11).translateToString(), 'efgh'); + assert.equal(buffer.lines.get(12).translateToString(), 'ijkl'); + for (let i = 13; i < 17; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + const wrappedLines: number[] = []; + for (let i = 0; i < buffer.lines.length; i++) { + assert.equal(buffer.lines.get(i).isWrapped, wrappedLines.indexOf(i) !== -1, `line ${i} isWrapped must equal ${wrappedLines.indexOf(i) !== -1}`); + } + }); + }); + }); + }); + }); + describe('reflowSmaller cases', () => { + beforeEach(() => { + // Setup buffer state: + // 'abcd' + // 'efgh' (wrapped) + // 'ijkl' + // ' ' + // ' ' + // ' ' + // ' ' + // ' ' + // ' ' + // ' ' + buffer.fillViewportRows(); + buffer.resize(4, 10); + buffer.lines.get(0).set(0, [null, 'a', 1, 'a'.charCodeAt(0)]); + buffer.lines.get(0).set(1, [null, 'b', 1, 'b'.charCodeAt(0)]); + buffer.lines.get(0).set(2, [null, 'c', 1, 'c'.charCodeAt(0)]); + buffer.lines.get(0).set(3, [null, 'd', 1, 'd'.charCodeAt(0)]); + buffer.lines.get(1).set(0, [null, 'e', 1, 'e'.charCodeAt(0)]); + buffer.lines.get(1).set(1, [null, 'f', 1, 'f'.charCodeAt(0)]); + buffer.lines.get(1).set(2, [null, 'g', 1, 'g'.charCodeAt(0)]); + buffer.lines.get(1).set(3, [null, 'h', 1, 'h'.charCodeAt(0)]); + buffer.lines.get(2).set(0, [null, 'i', 1, 'i'.charCodeAt(0)]); + buffer.lines.get(2).set(1, [null, 'j', 1, 'j'.charCodeAt(0)]); + buffer.lines.get(2).set(2, [null, 'k', 1, 'k'.charCodeAt(0)]); + buffer.lines.get(2).set(3, [null, 'l', 1, 'l'.charCodeAt(0)]); + }); + describe('viewport not yet filled', () => { + it('should move the cursor down', () => { + buffer.y = 3; + buffer.resize(2, 10); + assert.equal(buffer.y, 6); + assert.equal(buffer.ydisp, 0); + assert.equal(buffer.ybase, 0); + assert.equal(buffer.lines.length, 10); + assert.equal(buffer.lines.get(0).translateToString(), 'ab'); + assert.equal(buffer.lines.get(1).translateToString(), 'cd'); + assert.equal(buffer.lines.get(2).translateToString(), 'ef'); + assert.equal(buffer.lines.get(3).translateToString(), 'gh'); + assert.equal(buffer.lines.get(4).translateToString(), 'ij'); + assert.equal(buffer.lines.get(5).translateToString(), 'kl'); + for (let i = 6; i < 10; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + const wrappedLines = [1, 3, 5]; + for (let i = 0; i < buffer.lines.length; i++) { + assert.equal(buffer.lines.get(i).isWrapped, wrappedLines.indexOf(i) !== -1, `line ${i} isWrapped must equal ${wrappedLines.indexOf(i) !== -1}`); + } + }); + }); + describe('viewport filled, scrollback remaining', () => { + beforeEach(() => { + buffer.y = 9; + }); + describe('ybase === 0', () => { + it('should trim the top', () => { + buffer.resize(2, 10); + assert.equal(buffer.y, 9); + assert.equal(buffer.ydisp, 3); + assert.equal(buffer.ybase, 3); + assert.equal(buffer.lines.length, 13); + assert.equal(buffer.lines.get(0).translateToString(), 'ab'); + assert.equal(buffer.lines.get(1).translateToString(), 'cd'); + assert.equal(buffer.lines.get(2).translateToString(), 'ef'); + assert.equal(buffer.lines.get(3).translateToString(), 'gh'); + assert.equal(buffer.lines.get(4).translateToString(), 'ij'); + assert.equal(buffer.lines.get(5).translateToString(), 'kl'); + for (let i = 6; i < 13; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + const wrappedLines = [1, 3, 5]; + for (let i = 0; i < buffer.lines.length; i++) { + assert.equal(buffer.lines.get(i).isWrapped, wrappedLines.indexOf(i) !== -1, `line ${i} isWrapped must equal ${wrappedLines.indexOf(i) !== -1}`); + } + }); + }); + describe('ybase !== 0', () => { + beforeEach(() => { + // Add 10 empty rows to start + for (let i = 0; i < 10; i++) { + buffer.lines.splice(0, 0, buffer.getBlankLine(DEFAULT_ATTR)); + } + buffer.ybase = 10; + }); + describe('&& ydisp === ybase', () => { + it('should adjust the viewport and keep ydisp = ybase', () => { + buffer.ydisp = 10; + buffer.resize(2, 10); + assert.equal(buffer.ydisp, 13); + assert.equal(buffer.ybase, 13); + assert.equal(buffer.lines.length, 23); + for (let i = 0; i < 10; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + assert.equal(buffer.lines.get(10).translateToString(), 'ab'); + assert.equal(buffer.lines.get(11).translateToString(), 'cd'); + assert.equal(buffer.lines.get(12).translateToString(), 'ef'); + assert.equal(buffer.lines.get(13).translateToString(), 'gh'); + assert.equal(buffer.lines.get(14).translateToString(), 'ij'); + assert.equal(buffer.lines.get(15).translateToString(), 'kl'); + for (let i = 16; i < 23; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + const wrappedLines = [11, 13, 15]; + for (let i = 0; i < buffer.lines.length; i++) { + assert.equal(buffer.lines.get(i).isWrapped, wrappedLines.indexOf(i) !== -1, `line ${i} isWrapped must equal ${wrappedLines.indexOf(i) !== -1}`); + } + }); + }); + describe('&& ydisp !== ybase', () => { + it('should keep ydisp at the same value', () => { + buffer.ydisp = 5; + buffer.resize(2, 10); + assert.equal(buffer.ydisp, 5); + assert.equal(buffer.ybase, 13); + assert.equal(buffer.lines.length, 23); + for (let i = 0; i < 10; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + assert.equal(buffer.lines.get(10).translateToString(), 'ab'); + assert.equal(buffer.lines.get(11).translateToString(), 'cd'); + assert.equal(buffer.lines.get(12).translateToString(), 'ef'); + assert.equal(buffer.lines.get(13).translateToString(), 'gh'); + assert.equal(buffer.lines.get(14).translateToString(), 'ij'); + assert.equal(buffer.lines.get(15).translateToString(), 'kl'); + for (let i = 16; i < 23; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + const wrappedLines = [11, 13, 15]; + for (let i = 0; i < buffer.lines.length; i++) { + assert.equal(buffer.lines.get(i).isWrapped, wrappedLines.indexOf(i) !== -1, `line ${i} isWrapped must equal ${wrappedLines.indexOf(i) !== -1}`); + } + }); + }); + }); + }); + describe('viewport filled, no scrollback remaining', () => { + // ybase === 0 doesn't make sense here as scrollback=0 isn't really supported + describe('ybase !== 0', () => { + beforeEach(() => { + terminal.options.scrollback = 10; + // Add 10 empty rows to start + for (let i = 0; i < 10; i++) { + buffer.lines.splice(0, 0, buffer.getBlankLine(DEFAULT_ATTR)); + } + buffer.ybase = 10; + }); + describe('&& ydisp === ybase', () => { + it('should trim lines and keep ydisp = ybase', () => { + buffer.ydisp = 10; + buffer.resize(2, 10); + assert.equal(buffer.ydisp, 10); + assert.equal(buffer.ybase, 10); + assert.equal(buffer.lines.length, 20); + for (let i = 0; i < 7; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + assert.equal(buffer.lines.get(7).translateToString(), 'ab'); + assert.equal(buffer.lines.get(8).translateToString(), 'cd'); + assert.equal(buffer.lines.get(9).translateToString(), 'ef'); + assert.equal(buffer.lines.get(10).translateToString(), 'gh'); + assert.equal(buffer.lines.get(11).translateToString(), 'ij'); + assert.equal(buffer.lines.get(12).translateToString(), 'kl'); + for (let i = 13; i < 20; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + const wrappedLines = [8, 10, 12]; + for (let i = 0; i < buffer.lines.length; i++) { + assert.equal(buffer.lines.get(i).isWrapped, wrappedLines.indexOf(i) !== -1, `line ${i} isWrapped must equal ${wrappedLines.indexOf(i) !== -1}`); + } + }); + }); + describe('&& ydisp !== ybase', () => { + it('should trim lines and not change ydisp', () => { + buffer.ydisp = 5; + buffer.resize(2, 10); + assert.equal(buffer.ydisp, 5); + assert.equal(buffer.ybase, 10); + assert.equal(buffer.lines.length, 20); + for (let i = 0; i < 7; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + assert.equal(buffer.lines.get(7).translateToString(), 'ab'); + assert.equal(buffer.lines.get(8).translateToString(), 'cd'); + assert.equal(buffer.lines.get(9).translateToString(), 'ef'); + assert.equal(buffer.lines.get(10).translateToString(), 'gh'); + assert.equal(buffer.lines.get(11).translateToString(), 'ij'); + assert.equal(buffer.lines.get(12).translateToString(), 'kl'); + for (let i = 13; i < 20; i++) { + assert.equal(buffer.lines.get(i).translateToString(), ' '); + } + const wrappedLines = [8, 10, 12]; + for (let i = 0; i < buffer.lines.length; i++) { + assert.equal(buffer.lines.get(i).isWrapped, wrappedLines.indexOf(i) !== -1, `line ${i} isWrapped must equal ${wrappedLines.indexOf(i) !== -1}`); + } + }); + }); + }); + }); + }); + }); }); describe('buffer marked to have no scrollback', () => { diff --git a/src/Buffer.ts b/src/Buffer.ts index 625a2497e7..52d9572d5c 100644 --- a/src/Buffer.ts +++ b/src/Buffer.ts @@ -3,12 +3,13 @@ * @license MIT */ -import { CircularList } from './common/CircularList'; +import { CircularList, IInsertEvent, IDeleteEvent } from './common/CircularList'; import { CharData, ITerminal, IBuffer, IBufferLine, BufferIndex, IBufferStringIterator, IBufferStringIteratorResult } from './Types'; import { EventEmitter } from './common/EventEmitter'; import { IMarker } from 'xterm'; import { BufferLine } from './BufferLine'; import { DEFAULT_COLOR } from './renderer/atlas/Types'; +import { reflowSmallerGetNewLineLengths, reflowLargerGetLinesToRemove, reflowLargerCreateNewLayout, reflowLargerApplyNewLayout } from './BufferReflow'; export const DEFAULT_ATTR = (0 << 18) | (DEFAULT_COLOR << 9) | (256 << 0); export const CHAR_DATA_ATTR_INDEX = 0; @@ -25,6 +26,8 @@ export const WHITESPACE_CELL_CHAR = ' '; export const WHITESPACE_CELL_WIDTH = 1; export const WHITESPACE_CELL_CODE = 32; +export const FILL_CHAR_DATA: CharData = [DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]; + /** * This class represents a terminal buffer (an internal state of the terminal), where the * following information is stored (in high-level): @@ -45,6 +48,8 @@ export class Buffer implements IBuffer { public savedX: number; public savedCurAttr: number; public markers: Marker[] = []; + private _cols: number; + private _rows: number; /** * Create a new Buffer. @@ -56,22 +61,24 @@ export class Buffer implements IBuffer { private _terminal: ITerminal, private _hasScrollback: boolean ) { + this._cols = this._terminal.cols; + this._rows = this._terminal.rows; this.clear(); } public getBlankLine(attr: number, isWrapped?: boolean): IBufferLine { const fillCharData: CharData = [attr, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]; - return new BufferLine(this._terminal.cols, fillCharData, isWrapped); + return new BufferLine(this._cols, fillCharData, isWrapped); } public get hasScrollback(): boolean { - return this._hasScrollback && this.lines.maxLength > this._terminal.rows; + return this._hasScrollback && this.lines.maxLength > this._rows; } public get isCursorInViewport(): boolean { const absoluteY = this.ybase + this.y; const relativeY = absoluteY - this.ydisp; - return (relativeY >= 0 && relativeY < this._terminal.rows); + return (relativeY >= 0 && relativeY < this._rows); } /** @@ -97,7 +104,7 @@ export class Buffer implements IBuffer { if (fillAttr === undefined) { fillAttr = DEFAULT_ATTR; } - let i = this._terminal.rows; + let i = this._rows; while (i--) { this.lines.push(this.getBlankLine(fillAttr)); } @@ -112,9 +119,9 @@ export class Buffer implements IBuffer { this.ybase = 0; this.y = 0; this.x = 0; - this.lines = new CircularList(this._getCorrectBufferLength(this._terminal.rows)); + this.lines = new CircularList(this._getCorrectBufferLength(this._rows)); this.scrollTop = 0; - this.scrollBottom = this._terminal.rows - 1; + this.scrollBottom = this._rows - 1; this.setupTabStops(); } @@ -134,18 +141,17 @@ export class Buffer implements IBuffer { // The following adjustments should only happen if the buffer has been // initialized/filled. if (this.lines.length > 0) { - // Deal with columns increasing (we don't do anything when columns reduce) - if (this._terminal.cols < newCols) { - const ch: CharData = [DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]; // does xterm use the default attr? + // Deal with columns increasing (reducing needs to happen after reflow) + if (this._cols < newCols) { for (let i = 0; i < this.lines.length; i++) { - this.lines.get(i).resize(newCols, ch); + this.lines.get(i).resize(newCols, FILL_CHAR_DATA); } } // Resize rows in both directions as needed let addToY = 0; - if (this._terminal.rows < newRows) { - for (let y = this._terminal.rows; y < newRows; y++) { + if (this._rows < newRows) { + for (let y = this._rows; y < newRows; y++) { if (this.lines.length < newRows + this.ybase) { if (this.ybase > 0 && this.lines.length <= this.ybase + this.y + addToY + 1) { // There is room above the buffer and there are no empty elements below the line, @@ -159,13 +165,12 @@ export class Buffer implements IBuffer { } else { // Add a blank line if there is no buffer left at the top to scroll to, or if there // are blank lines after the cursor - const fillCharData: CharData = [DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]; - this.lines.push(new BufferLine(newCols, fillCharData)); + this.lines.push(new BufferLine(newCols, FILL_CHAR_DATA)); } } } - } else { // (this._terminal.rows >= newRows) - for (let y = this._terminal.rows; y > newRows; y--) { + } else { // (this._rows >= newRows) + for (let y = this._rows; y > newRows; y--) { if (this.lines.length > newRows + this.ybase) { if (this.lines.length > this.ybase + this.y + 1) { // The line is a blank line below the cursor, remove it @@ -205,8 +210,218 @@ export class Buffer implements IBuffer { } this.scrollBottom = newRows - 1; + + if (this._hasScrollback) { + this._reflow(newCols); + + // Trim the end of the line off if cols shrunk + if (this._cols > newCols) { + for (let i = 0; i < this.lines.length; i++) { + this.lines.get(i).resize(newCols, FILL_CHAR_DATA); + } + } + } + + this._cols = newCols; + this._rows = newRows; + } + + private _reflow(newCols: number): void { + if (this._cols === newCols) { + return; + } + + // Iterate through rows, ignore the last one as it cannot be wrapped + if (newCols > this._cols) { + this._reflowLarger(newCols); + } else { + this._reflowSmaller(newCols); + } + } + + private _reflowLarger(newCols: number): void { + const toRemove: number[] = reflowLargerGetLinesToRemove(this.lines, newCols); + if (toRemove.length > 0) { + const newLayoutResult = reflowLargerCreateNewLayout(this.lines, toRemove); + reflowLargerApplyNewLayout(this.lines, newLayoutResult.layout); + this._reflowLargerAdjustViewport(newCols, newLayoutResult.countRemoved); + } + } + + private _reflowLargerAdjustViewport(newCols: number, countRemoved: number): void { + // Adjust viewport based on number of items removed + let viewportAdjustments = countRemoved; + while (viewportAdjustments-- > 0) { + if (this.ybase === 0) { + this.y--; + // Add an extra row at the bottom of the viewport + this.lines.push(new BufferLine(newCols, FILL_CHAR_DATA)); + } else { + if (this.ydisp === this.ybase) { + this.ydisp--; + } + this.ybase--; + } + } } + private _reflowSmaller(newCols: number): void { + // Gather all BufferLines that need to be inserted into the Buffer here so that they can be + // batched up and only committed once + const toInsert = []; + let countToInsert = 0; + // Go backwards as many lines may be trimmed and this will avoid considering them + for (let y = this.lines.length - 1; y >= 0; y--) { + // Check whether this line is a problem + let nextLine = this.lines.get(y) as BufferLine; + if (!nextLine.isWrapped && nextLine.getTrimmedLength() <= newCols) { + continue; + } + + // Gather wrapped lines and adjust y to be the starting line + const wrappedLines: BufferLine[] = [nextLine]; + while (nextLine.isWrapped && y > 0) { + nextLine = this.lines.get(--y) as BufferLine; + wrappedLines.unshift(nextLine); + } + + const lastLineLength = wrappedLines[wrappedLines.length - 1].getTrimmedLength(); + const destLineLengths = reflowSmallerGetNewLineLengths(wrappedLines, this._cols, newCols); + const linesToAdd = destLineLengths.length - wrappedLines.length; + let trimmedLines: number; + if (this.ybase === 0 && this.y !== this.lines.length - 1) { + // If the top section of the buffer is not yet filled + trimmedLines = Math.max(0, this.y - this.lines.maxLength + linesToAdd); + } else { + trimmedLines = Math.max(0, this.lines.length - this.lines.maxLength + linesToAdd); + } + + // Add the new lines + const newLines: BufferLine[] = []; + for (let i = 0; i < linesToAdd; i++) { + const newLine = this.getBlankLine(DEFAULT_ATTR, true) as BufferLine; + newLines.push(newLine); + } + if (newLines.length > 0) { + toInsert.push({ + // countToInsert here gets the actual index, taking into account other inserted items. + // using this we can iterate through the list forwards + start: y + wrappedLines.length + countToInsert, + newLines + }); + countToInsert += newLines.length; + } + wrappedLines.push(...newLines); + + // Copy buffer data to new locations, this needs to happen backwards to do in-place + let destLineIndex = destLineLengths.length - 1; // Math.floor(cellsNeeded / newCols); + let destCol = destLineLengths[destLineIndex]; // cellsNeeded % newCols; + if (destCol === 0) { + destLineIndex--; + destCol = destLineLengths[destLineIndex]; + } + let srcLineIndex = wrappedLines.length - linesToAdd - 1; + let srcCol = lastLineLength; + while (srcLineIndex >= 0) { + const cellsToCopy = Math.min(srcCol, destCol); + wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[srcLineIndex], srcCol - cellsToCopy, destCol - cellsToCopy, cellsToCopy, true); + destCol -= cellsToCopy; + if (destCol === 0) { + destLineIndex--; + destCol = destLineLengths[destLineIndex]; + } + srcCol -= cellsToCopy; + if (srcCol === 0) { + srcLineIndex--; + // TODO: srcCol shoudl take trimmed length into account + srcCol = wrappedLines[Math.max(srcLineIndex, 0)].getTrimmedLength(); // this._cols; + } + } + + // Null out the end of the line ends if a wide character wrapped to the following line + for (let i = 0; i < wrappedLines.length; i++) { + if (destLineLengths[i] < newCols) { + wrappedLines[i].set(destLineLengths[i], FILL_CHAR_DATA); + } + } + + // Adjust viewport as needed + let viewportAdjustments = linesToAdd - trimmedLines; + while (viewportAdjustments-- > 0) { + if (this.ybase === 0) { + if (this.y < this._rows - 1) { + this.y++; + this.lines.pop(); + } else { + this.ybase++; + this.ydisp++; + } + } else { + if (this.ybase === this.ydisp) { + this.ydisp++; + } + this.ybase++; + } + } + } + + // Rearrange lines in the buffer if there are any insertions, this is done at the end rather + // than earlier so that it's a single O(n) pass through the buffer, instead of O(n^2) from many + // costly calls to CircularList.splice. + if (toInsert.length > 0) { + // Record buffer insert events and then play them back backwards so that the indexes are + // correct + const insertEvents: IInsertEvent[] = []; + + // Record original lines so they don't get overridden when we rearrange the list + const originalLines: BufferLine[] = []; + for (let i = 0; i < this.lines.length; i++) { + originalLines.push(this.lines.get(i) as BufferLine); + } + const originalLinesLength = this.lines.length; + + let originalLineIndex = originalLinesLength - 1; + let nextToInsertIndex = 0; + let nextToInsert = toInsert[nextToInsertIndex]; + this.lines.length = Math.min(this.lines.maxLength, this.lines.length + countToInsert); + let countInsertedSoFar = 0; + for (let i = Math.min(this.lines.maxLength - 1, originalLinesLength + countToInsert - 1); i >= 0; i--) { + if (nextToInsert && nextToInsert.start > originalLineIndex + countInsertedSoFar) { + // Insert extra lines here, adjusting i as needed + for (let nextI = nextToInsert.newLines.length - 1; nextI >= 0; nextI--) { + this.lines.set(i--, nextToInsert.newLines[nextI]); + } + i++; + + // Create insert events for later + insertEvents.push({ + index: originalLineIndex + 1, + amount: nextToInsert.newLines.length + } as IInsertEvent); + + countInsertedSoFar += nextToInsert.newLines.length; + nextToInsert = toInsert[++nextToInsertIndex]; + } else { + this.lines.set(i, originalLines[originalLineIndex--]); + } + } + + // Update markers + let insertCountEmitted = 0; + for (let i = insertEvents.length - 1; i >= 0; i--) { + insertEvents[i].index += insertCountEmitted; + this.lines.emit('insert', insertEvents[i]); + insertCountEmitted += insertEvents[i].amount; + } + const amountToTrim = Math.max(0, originalLinesLength + countToInsert - this.lines.maxLength); + if (amountToTrim > 0) { + this.lines.emitMayRemoveListeners('trim', amountToTrim); + } + } + } + + // private _reflowSmallerGetLinesNeeded() + /** * Translates a string index back to a BufferIndex. * To get the correct buffer position the string must start at `startCol` 0 @@ -283,7 +498,7 @@ export class Buffer implements IBuffer { i = 0; } - for (; i < this._terminal.cols; i += this._terminal.options.tabStopWidth) { + for (; i < this._cols; i += this._terminal.options.tabStopWidth) { this.tabs[i] = true; } } @@ -297,7 +512,7 @@ export class Buffer implements IBuffer { x = this.x; } while (!this.tabs[--x] && x > 0); - return x >= this._terminal.cols ? this._terminal.cols - 1 : x < 0 ? 0 : x; + return x >= this._cols ? this._cols - 1 : x < 0 ? 0 : x; } /** @@ -308,8 +523,8 @@ export class Buffer implements IBuffer { if (x === null || x === undefined) { x = this.x; } - while (!this.tabs[++x] && x < this._terminal.cols); - return x >= this._terminal.cols ? this._terminal.cols - 1 : x < 0 ? 0 : x; + while (!this.tabs[++x] && x < this._cols); + return x >= this._cols ? this._cols - 1 : x < 0 ? 0 : x; } public addMarker(y: number): Marker { @@ -322,12 +537,27 @@ export class Buffer implements IBuffer { marker.dispose(); } })); + marker.register(this.lines.addDisposableListener('insert', (event: IInsertEvent) => { + if (marker.line >= event.index) { + marker.line += event.amount; + } + })); + marker.register(this.lines.addDisposableListener('delete', (event: IDeleteEvent) => { + // Delete the marker if it's within the range + if (marker.line >= event.index && marker.line < event.index + event.amount) { + marker.dispose(); + } + + // Shift the marker if it's after the deleted range + if (marker.line > event.index) { + marker.line -= event.amount; + } + })); marker.register(marker.addDisposableListener('dispose', () => this._removeMarker(marker))); return marker; } private _removeMarker(marker: Marker): void { - // TODO: This could probably be optimized by relying on sort order and trimming the array using .length this.markers.splice(this.markers.indexOf(marker), 1); } diff --git a/src/BufferLine.test.ts b/src/BufferLine.test.ts index fbf8b0517b..0ef29505e3 100644 --- a/src/BufferLine.test.ts +++ b/src/BufferLine.test.ts @@ -9,6 +9,10 @@ import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, DEFAULT_ATTR } from '. class TestBufferLine extends BufferLine { + public get combined(): {[index: number]: string} { + return this._combined; + } + public toArray(): CharData[] { const result = []; for (let i = 0; i < this.length; ++i) { @@ -141,63 +145,30 @@ describe('BufferLine', function(): void { }); it('enlarge(true)', function(): void { const line = new TestBufferLine(5, [1, 'a', 0, 'a'.charCodeAt(0)], false); - line.resize(10, [1, 'a', 0, 'a'.charCodeAt(0)], true); + line.resize(10, [1, 'a', 0, 'a'.charCodeAt(0)]); chai.expect(line.toArray()).eql(Array(10).fill([1, 'a', 0, 'a'.charCodeAt(0)])); }); it('shrink(true) - should apply new size', function(): void { const line = new TestBufferLine(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); - line.resize(5, [1, 'a', 0, 'a'.charCodeAt(0)], true); + line.resize(5, [1, 'a', 0, 'a'.charCodeAt(0)]); chai.expect(line.toArray()).eql(Array(5).fill([1, 'a', 0, 'a'.charCodeAt(0)])); }); - it('shrink(false) - should not apply new size', function(): void { - const line = new TestBufferLine(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); - line.resize(5, [1, 'a', 0, 'a'.charCodeAt(0)], false); - chai.expect(line.toArray()).eql(Array(10).fill([1, 'a', 0, 'a'.charCodeAt(0)])); - }); - it('shrink(false) + shrink(false) - should not apply new size', function(): void { - const line = new TestBufferLine(20, [1, 'a', 0, 'a'.charCodeAt(0)], false); - line.resize(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); - line.resize(5, [1, 'a', 0, 'a'.charCodeAt(0)], false); - chai.expect(line.toArray()).eql(Array(20).fill([1, 'a', 0, 'a'.charCodeAt(0)])); - }); - it('shrink(false) + enlarge(false) to smaller than before', function(): void { - const line = new TestBufferLine(20, [1, 'a', 0, 'a'.charCodeAt(0)], false); - line.resize(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); - line.resize(15, [1, 'a', 0, 'a'.charCodeAt(0)]); - chai.expect(line.toArray()).eql(Array(20).fill([1, 'a', 0, 'a'.charCodeAt(0)])); - }); - it('shrink(false) + enlarge(false) to bigger than before', function(): void { - const line = new TestBufferLine(20, [1, 'a', 0, 'a'.charCodeAt(0)], false); - line.resize(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); - line.resize(25, [1, 'a', 0, 'a'.charCodeAt(0)]); - chai.expect(line.toArray()).eql(Array(25).fill([1, 'a', 0, 'a'.charCodeAt(0)])); - }); - it('shrink(false) + resize shrink=true should enforce shrinking', function(): void { - const line = new TestBufferLine(20, [1, 'a', 0, 'a'.charCodeAt(0)], false); - line.resize(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); - line.resize(10, [1, 'a', 0, 'a'.charCodeAt(0)], true); - chai.expect(line.toArray()).eql(Array(10).fill([1, 'a', 0, 'a'.charCodeAt(0)])); - }); - it('enlarge from 0 length', function(): void { - const line = new TestBufferLine(0, [1, 'a', 0, 'a'.charCodeAt(0)], false); - line.resize(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); - chai.expect(line.toArray()).eql(Array(10).fill([1, 'a', 0, 'a'.charCodeAt(0)])); - }); it('shrink to 0 length', function(): void { const line = new TestBufferLine(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); - line.resize(0, [1, 'a', 0, 'a'.charCodeAt(0)], true); + line.resize(0, [1, 'a', 0, 'a'.charCodeAt(0)]); chai.expect(line.toArray()).eql(Array(0).fill([1, 'a', 0, 'a'.charCodeAt(0)])); }); - it('shrink(false) to 0 and enlarge to different sizes', function(): void { + it('should remove combining data on replaced cells after shrinking then enlarging', () => { const line = new TestBufferLine(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); - line.resize(0, [1, 'a', 0, 'a'.charCodeAt(0)], false); - chai.expect(line.toArray()).eql(Array(10).fill([1, 'a', 0, 'a'.charCodeAt(0)])); - line.resize(5, [1, 'a', 0, 'a'.charCodeAt(0)], false); - chai.expect(line.toArray()).eql(Array(10).fill([1, 'a', 0, 'a'.charCodeAt(0)])); - line.resize(7, [1, 'a', 0, 'a'.charCodeAt(0)], false); - chai.expect(line.toArray()).eql(Array(10).fill([1, 'a', 0, 'a'.charCodeAt(0)])); - line.resize(7, [1, 'a', 0, 'a'.charCodeAt(0)], true); - chai.expect(line.toArray()).eql(Array(7).fill([1, 'a', 0, 'a'.charCodeAt(0)])); + line.set(2, [ null, '😁', 1, '😁'.charCodeAt(0) ]); + line.set(9, [ null, '😁', 1, '😁'.charCodeAt(0) ]); + chai.expect(line.translateToString()).eql('aa😁aaaaaa😁'); + chai.expect(Object.keys(line.combined).length).eql(2); + line.resize(5, [1, 'a', 0, 'a'.charCodeAt(0)]); + chai.expect(line.translateToString()).eql('aa😁aa'); + line.resize(10, [1, 'a', 0, 'a'.charCodeAt(0)]); + chai.expect(line.translateToString()).eql('aa😁aaaaaaa'); + chai.expect(Object.keys(line.combined).length).eql(1); }); }); describe('getTrimLength', function(): void { diff --git a/src/BufferLine.ts b/src/BufferLine.ts index 226c4a87eb..6bc305860c 100644 --- a/src/BufferLine.ts +++ b/src/BufferLine.ts @@ -54,6 +54,10 @@ export class BufferLine implements IBufferLine { ]; } + public getWidth(index: number): number { + return this._data[index * CELL_SIZE + Cell.WIDTH]; + } + public set(index: number, value: CharData): void { this._data[index * CELL_SIZE + Cell.FLAGS] = value[0]; if (value[1].length > 1) { @@ -103,8 +107,8 @@ export class BufferLine implements IBufferLine { } } - public resize(cols: number, fillCharData: CharData, shrink: boolean = false): void { - if (cols === this.length || (!shrink && cols < this.length)) { + public resize(cols: number, fillCharData: CharData): void { + if (cols === this.length) { return; } if (cols > this.length) { @@ -120,13 +124,22 @@ export class BufferLine implements IBufferLine { for (let i = this.length; i < cols; ++i) { this.set(i, fillCharData); } - } else if (shrink) { + } else { if (cols) { const data = new Uint32Array(cols * CELL_SIZE); data.set(this._data.subarray(0, cols * CELL_SIZE)); this._data = data; + // Remove any cut off combined data + const keys = Object.keys(this._combined); + for (let i = 0; i < keys.length; i++) { + const key = parseInt(keys[i], 10); + if (key >= cols) { + delete this._combined[key]; + } + } } else { this._data = null; + this._combined = {}; } } this.length = cols; @@ -179,6 +192,32 @@ export class BufferLine implements IBufferLine { return 0; } + public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void { + const srcData = src._data; + if (applyInReverse) { + for (let cell = length - 1; cell >= 0; cell--) { + for (let i = 0; i < CELL_SIZE; i++) { + this._data[(destCol + cell) * CELL_SIZE + i] = srcData[(srcCol + cell) * CELL_SIZE + i]; + } + } + } else { + for (let cell = 0; cell < length; cell++) { + for (let i = 0; i < CELL_SIZE; i++) { + this._data[(destCol + cell) * CELL_SIZE + i] = srcData[(srcCol + cell) * CELL_SIZE + i]; + } + } + } + + // Move any combined data over as needed + const srcCombinedKeys = Object.keys(src._combined); + for (let i = 0; i < srcCombinedKeys.length; i++) { + const key = parseInt(srcCombinedKeys[i], 10); + if (key >= srcCol) { + this._combined[key - srcCol + destCol] = src._combined[key]; + } + } + } + public translateToString(trimRight: boolean = false, startCol: number = 0, endCol: number = this.length): string { if (trimRight) { endCol = Math.min(endCol, this.getTrimmedLength()); diff --git a/src/BufferReflow.test.ts b/src/BufferReflow.test.ts new file mode 100644 index 0000000000..9c978dc0ab --- /dev/null +++ b/src/BufferReflow.test.ts @@ -0,0 +1,93 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ +import { assert } from 'chai'; +import { BufferLine } from './BufferLine'; +import { reflowSmallerGetNewLineLengths } from './BufferReflow'; +import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE } from './Buffer'; + +describe('BufferReflow', () => { + describe('reflowSmallerGetNewLineLengths', () => { + it('should return correct line lengths for a small line with wide characters', () => { + const line = new BufferLine(4); + line.set(0, [null, '汉', 2, '汉'.charCodeAt(0)]); + line.set(1, [null, '', 0, undefined]); + line.set(2, [null, '语', 2, '语'.charCodeAt(0)]); + line.set(3, [null, '', 0, undefined]); + assert.equal(line.translateToString(true), '汉语'); + assert.deepEqual(reflowSmallerGetNewLineLengths([line], 4, 3), [2, 2], 'line: 汉, 语'); + assert.deepEqual(reflowSmallerGetNewLineLengths([line], 4, 2), [2, 2], 'line: 汉, 语'); + }); + it('should return correct line lengths for a large line with wide characters', () => { + const line = new BufferLine(12); + for (let i = 0; i < 12; i += 4) { + line.set(i, [null, '汉', 2, '汉'.charCodeAt(0)]); + line.set(i + 2, [null, '语', 2, '语'.charCodeAt(0)]); + } + for (let i = 1; i < 12; i += 2) { + line.set(i, [null, '', 0, undefined]); + line.set(i, [null, '', 0, undefined]); + } + assert.equal(line.translateToString(), '汉语汉语汉语'); + assert.deepEqual(reflowSmallerGetNewLineLengths([line], 12, 11), [10, 2], 'line: 汉语汉语汉, 语'); + assert.deepEqual(reflowSmallerGetNewLineLengths([line], 12, 10), [10, 2], 'line: 汉语汉语汉, 语'); + assert.deepEqual(reflowSmallerGetNewLineLengths([line], 12, 9), [8, 4], 'line: 汉语汉语, 汉语'); + assert.deepEqual(reflowSmallerGetNewLineLengths([line], 12, 8), [8, 4], 'line: 汉语汉语, 汉语'); + assert.deepEqual(reflowSmallerGetNewLineLengths([line], 12, 7), [6, 6], 'line: 汉语汉, 语汉语'); + assert.deepEqual(reflowSmallerGetNewLineLengths([line], 12, 6), [6, 6], 'line: 汉语汉, 语汉语'); + assert.deepEqual(reflowSmallerGetNewLineLengths([line], 12, 5), [4, 4, 4], 'line: 汉语, 汉语, 汉语'); + assert.deepEqual(reflowSmallerGetNewLineLengths([line], 12, 4), [4, 4, 4], 'line: 汉语, 汉语, 汉语'); + assert.deepEqual(reflowSmallerGetNewLineLengths([line], 12, 3), [2, 2, 2, 2, 2, 2], 'line: 汉, 语, 汉, 语, 汉, 语'); + assert.deepEqual(reflowSmallerGetNewLineLengths([line], 12, 2), [2, 2, 2, 2, 2, 2], 'line: 汉, 语, 汉, 语, 汉, 语'); + }); + it('should return correct line lengths for a string with wide and single characters', () => { + const line = new BufferLine(6); + line.set(0, [null, 'a', 1, 'a'.charCodeAt(0)]); + line.set(1, [null, '汉', 2, '汉'.charCodeAt(0)]); + line.set(2, [null, '', 0, undefined]); + line.set(3, [null, '语', 2, '语'.charCodeAt(0)]); + line.set(4, [null, '', 0, undefined]); + line.set(5, [null, 'b', 1, 'b'.charCodeAt(0)]); + assert.equal(line.translateToString(), 'a汉语b'); + assert.deepEqual(reflowSmallerGetNewLineLengths([line], 6, 5), [5, 1], 'line: a汉语b'); + assert.deepEqual(reflowSmallerGetNewLineLengths([line], 6, 4), [3, 3], 'line: a汉, 语b'); + assert.deepEqual(reflowSmallerGetNewLineLengths([line], 6, 3), [3, 3], 'line: a汉, 语b'); + assert.deepEqual(reflowSmallerGetNewLineLengths([line], 6, 2), [1, 2, 2, 1], 'line: a, 汉, 语, b'); + }); + it('should return correct line lengths for a wrapped line with wide and single characters', () => { + const line1 = new BufferLine(6); + line1.set(0, [null, 'a', 1, 'a'.charCodeAt(0)]); + line1.set(1, [null, '汉', 2, '汉'.charCodeAt(0)]); + line1.set(2, [null, '', 0, undefined]); + line1.set(3, [null, '语', 2, '语'.charCodeAt(0)]); + line1.set(4, [null, '', 0, undefined]); + line1.set(5, [null, 'b', 1, 'b'.charCodeAt(0)]); + const line2 = new BufferLine(6, undefined, true); + line2.set(0, [null, 'a', 1, 'a'.charCodeAt(0)]); + line2.set(1, [null, '汉', 2, '汉'.charCodeAt(0)]); + line2.set(2, [null, '', 0, undefined]); + line2.set(3, [null, '语', 2, '语'.charCodeAt(0)]); + line2.set(4, [null, '', 0, undefined]); + line2.set(5, [null, 'b', 1, 'b'.charCodeAt(0)]); + assert.equal(line1.translateToString(), 'a汉语b'); + assert.equal(line2.translateToString(), 'a汉语b'); + assert.deepEqual(reflowSmallerGetNewLineLengths([line1, line2], 6, 5), [5, 4, 3], 'lines: a汉语, ba汉, 语b'); + assert.deepEqual(reflowSmallerGetNewLineLengths([line1, line2], 6, 4), [3, 4, 4, 1], 'lines: a汉, 语ba, 汉语, b'); + assert.deepEqual(reflowSmallerGetNewLineLengths([line1, line2], 6, 3), [3, 3, 3, 3], 'lines: a汉, 语b, a汉, 语b'); + assert.deepEqual(reflowSmallerGetNewLineLengths([line1, line2], 6, 2), [1, 2, 2, 2, 2, 2, 1], 'lines: a, 汉, 语, ba, 汉, 语, b'); + }); + it('should work on lines ending in null space', () => { + const line = new BufferLine(5); + line.set(0, [null, '汉', 2, '汉'.charCodeAt(0)]); + line.set(1, [null, '', 0, undefined]); + line.set(2, [null, '语', 2, '语'.charCodeAt(0)]); + line.set(3, [null, '', 0, undefined]); + line.set(4, [null, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]); + assert.equal(line.translateToString(true), '汉语'); + assert.equal(line.translateToString(false), '汉语 '); + assert.deepEqual(reflowSmallerGetNewLineLengths([line], 4, 3), [2, 2], 'line: 汉, 语'); + assert.deepEqual(reflowSmallerGetNewLineLengths([line], 4, 2), [2, 2], 'line: 汉, 语'); + }); + }); +}); diff --git a/src/BufferReflow.ts b/src/BufferReflow.ts new file mode 100644 index 0000000000..59934e46cb --- /dev/null +++ b/src/BufferReflow.ts @@ -0,0 +1,199 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { BufferLine } from './BufferLine'; +import { CircularList, IDeleteEvent } from './common/CircularList'; +import { IBufferLine } from './Types'; +import { FILL_CHAR_DATA } from './Buffer'; + +export interface INewLayoutResult { + layout: number[]; + countRemoved: number; +} + +/** + * Evaluates and returns indexes to be removed after a reflow larger occurs. Lines will be removed + * when a wrapped line unwraps. + * @param lines The buffer lines. + * @param newCols The columns after resize. + */ +export function reflowLargerGetLinesToRemove(lines: CircularList, newCols: number): number[] { + // Gather all BufferLines that need to be removed from the Buffer here so that they can be + // batched up and only committed once + const toRemove: number[] = []; + + for (let y = 0; y < lines.length - 1; y++) { + // Check if this row is wrapped + let i = y; + let nextLine = lines.get(++i) as BufferLine; + if (!nextLine.isWrapped) { + continue; + } + + // Check how many lines it's wrapped for + const wrappedLines: BufferLine[] = [lines.get(y) as BufferLine]; + while (i < lines.length && nextLine.isWrapped) { + wrappedLines.push(nextLine); + nextLine = lines.get(++i) as BufferLine; + } + + // Copy buffer data to new locations + let destLineIndex = 0; + let destCol = wrappedLines[destLineIndex].getTrimmedLength(); + let srcLineIndex = 1; + let srcCol = 0; + while (srcLineIndex < wrappedLines.length) { + const srcTrimmedTineLength = wrappedLines[srcLineIndex].getTrimmedLength(); + const srcRemainingCells = srcTrimmedTineLength - srcCol; + const destRemainingCells = newCols - destCol; + const cellsToCopy = Math.min(srcRemainingCells, destRemainingCells); + + wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[srcLineIndex], srcCol, destCol, cellsToCopy, false); + + destCol += cellsToCopy; + if (destCol === newCols) { + destLineIndex++; + destCol = 0; + } + srcCol += cellsToCopy; + if (srcCol === srcTrimmedTineLength) { + srcLineIndex++; + srcCol = 0; + } + + // Make sure the last cell isn't wide, if it is copy it to the current dest + if (destCol === 0) { + if (wrappedLines[destLineIndex - 1].getWidth(newCols - 1) === 2) { + wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[destLineIndex - 1], newCols - 1, destCol++, 1, false); + // Null out the end of the last row + wrappedLines[destLineIndex - 1].set(newCols - 1, FILL_CHAR_DATA); + } + } + } + + // Clear out remaining cells or fragments could remain; + wrappedLines[destLineIndex].replaceCells(destCol, newCols, FILL_CHAR_DATA); + + // Work backwards and remove any rows at the end that only contain null cells + let countToRemove = 0; + for (let i = wrappedLines.length - 1; i > 0; i--) { + if (i > destLineIndex || wrappedLines[i].getTrimmedLength() === 0) { + countToRemove++; + } else { + break; + } + } + + if (countToRemove > 0) { + toRemove.push(y + wrappedLines.length - countToRemove); // index + toRemove.push(countToRemove); + } + + y += wrappedLines.length - 1; + } + return toRemove; +} + +/** + * Creates and return the new layout for lines given an array of indexes to be removed. + * @param lines The buffer lines. + * @param toRemove The indexes to remove. + */ +export function reflowLargerCreateNewLayout(lines: CircularList, toRemove: number[]): INewLayoutResult { + const layout: number[] = []; + // First iterate through the list and get the actual indexes to use for rows + let nextToRemoveIndex = 0; + let nextToRemoveStart = toRemove[nextToRemoveIndex]; + let countRemovedSoFar = 0; + for (let i = 0; i < lines.length; i++) { + if (nextToRemoveStart === i) { + const countToRemove = toRemove[++nextToRemoveIndex]; + + // Tell markers that there was a deletion + lines.emit('delete', { + index: i - countRemovedSoFar, + amount: countToRemove + } as IDeleteEvent); + + i += countToRemove - 1; + countRemovedSoFar += countToRemove; + nextToRemoveStart = toRemove[++nextToRemoveIndex]; + } else { + layout.push(i); + } + } + return { + layout, + countRemoved: countRemovedSoFar + }; +} + +/** + * Applies a new layout to the buffer. This essentially does the same as many splice calls but it's + * done all at once in a single iteration through the list since splice is very expensive. + * @param lines The buffer lines. + * @param newLayout The new layout to apply. + */ +export function reflowLargerApplyNewLayout(lines: CircularList, newLayout: number[]): void { + // Record original lines so they don't get overridden when we rearrange the list + const newLayoutLines: BufferLine[] = []; + for (let i = 0; i < newLayout.length; i++) { + newLayoutLines.push(lines.get(newLayout[i]) as BufferLine); + } + + // Rearrange the list + for (let i = 0; i < newLayoutLines.length; i++) { + lines.set(i, newLayoutLines[i]); + } + lines.length = newLayout.length; +} + +/** + * Gets the new line lengths for a given wrapped line. The purpose of this function it to pre- + * compute the wrapping points since wide characters may need to be wrapped onto the following line. + * This function will return an array of numbers of where each line wraps to, the resulting array + * will only contain the values `newCols` (when the line does not end with a wide character) and + * `newCols - 1` (when the line does end with a wide character), except for the last value which + * will contain the remaining items to fill the line. + * + * Calling this with a `newCols` value of `1` will lock up. + * + * @param wrappedLines The wrapped lines to evaluate. + * @param oldCols The columns before resize. + * @param newCols The columns after resize. + */ +export function reflowSmallerGetNewLineLengths(wrappedLines: BufferLine[], oldCols: number, newCols: number): number[] { + const newLineLengths: number[] = []; + + const cellsNeeded = wrappedLines.map(l => l.getTrimmedLength()).reduce((p, c) => p + c); + + // Use srcCol and srcLine to find the new wrapping point, use that to get the cellsAvailable and + // linesNeeded + let srcCol = 0; + let srcLine = 0; + let cellsAvailable = 0; + while (cellsAvailable < cellsNeeded) { + if (cellsNeeded - cellsAvailable < newCols) { + // Add the final line and exit the loop + newLineLengths.push(cellsNeeded - cellsAvailable); + break; + } + srcCol += newCols; + const oldTrimmedLength = wrappedLines[srcLine].getTrimmedLength(); + if (srcCol > oldTrimmedLength) { + srcCol -= oldTrimmedLength; + srcLine++; + } + const endsWithWide = wrappedLines[srcLine].getWidth(srcCol - 1) === 2; + if (endsWithWide) { + srcCol--; + } + const lineLength = endsWithWide ? newCols - 1 : newCols; + newLineLengths.push(lineLength); + cellsAvailable += lineLength; + } + + return newLineLengths; +} diff --git a/src/Terminal.ts b/src/Terminal.ts index b7d5bf7f9a..4ddd0431df 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -69,6 +69,9 @@ const WRITE_BUFFER_PAUSE_THRESHOLD = 5; */ const WRITE_BATCH_SIZE = 300; +const MINIMUM_COLS = 2; // Less than 2 can mess with wide chars +const MINIMUM_ROWS = 1; + /** * The set of options that only have an effect when set in the Terminal constructor. */ @@ -262,8 +265,8 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II // TODO: WHy not document.body? this._parent = document ? document.body : null; - this.cols = this.options.cols; - this.rows = this.options.rows; + this.cols = Math.max(this.options.cols, MINIMUM_COLS); + this.rows = Math.max(this.options.rows, MINIMUM_ROWS); if (this.options.handler) { this.on('data', this.options.handler); @@ -1693,8 +1696,8 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II return; } - if (x < 1) x = 1; - if (y < 1) y = 1; + if (x < MINIMUM_COLS) x = MINIMUM_COLS; + if (y < MINIMUM_ROWS) y = MINIMUM_ROWS; this.buffers.resize(x, y); diff --git a/src/Types.ts b/src/Types.ts index c6395ea3ca..aac029f215 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -522,7 +522,7 @@ export interface IBufferLine { insertCells(pos: number, n: number, ch: CharData): void; deleteCells(pos: number, n: number, fill: CharData): void; replaceCells(start: number, end: number, fill: CharData): void; - resize(cols: number, fill: CharData, shrink?: boolean): void; + resize(cols: number, fill: CharData): void; fill(fillCharData: CharData): void; copyFrom(line: IBufferLine): void; clone(): IBufferLine; diff --git a/src/common/CircularList.ts b/src/common/CircularList.ts index 9faf534aef..af4d8af507 100644 --- a/src/common/CircularList.ts +++ b/src/common/CircularList.ts @@ -6,6 +6,16 @@ import { EventEmitter } from './EventEmitter'; import { ICircularList } from './Types'; +export interface IInsertEvent { + index: number; + amount: number; +} + +export interface IDeleteEvent { + index: number; + amount: number; +} + /** * Represents a circular list; a list with a maximum size that wraps around when push is called, * overriding values at the start of the list. @@ -91,7 +101,7 @@ export class CircularList extends EventEmitter implements ICircularList { this._array[this._getCyclicIndex(this._length)] = value; if (this._length === this._maxLength) { this._startIndex = ++this._startIndex % this._maxLength; - this.emit('trim', 1); + this.emitMayRemoveListeners('trim', 1); } else { this._length++; } @@ -107,7 +117,7 @@ export class CircularList extends EventEmitter implements ICircularList { throw new Error('Can only recycle when the buffer is full'); } this._startIndex = ++this._startIndex % this._maxLength; - this.emit('trim', 1); + this.emitMayRemoveListeners('trim', 1); return this._array[this._getCyclicIndex(this._length - 1)]!; } @@ -158,7 +168,7 @@ export class CircularList extends EventEmitter implements ICircularList { const countToTrim = (this._length + items.length) - this._maxLength; this._startIndex += countToTrim; this._length = this._maxLength; - this.emit('trim', countToTrim); + this.emitMayRemoveListeners('trim', countToTrim); } else { this._length += items.length; } @@ -175,7 +185,7 @@ export class CircularList extends EventEmitter implements ICircularList { } this._startIndex += count; this._length -= count; - this.emit('trim', count); + this.emitMayRemoveListeners('trim', count); } public shiftElements(start: number, count: number, offset: number): void { @@ -199,7 +209,7 @@ export class CircularList extends EventEmitter implements ICircularList { while (this._length > this._maxLength) { this._length--; this._startIndex++; - this.emit('trim', 1); + this.emitMayRemoveListeners('trim', 1); } } } else { diff --git a/src/common/EventEmitter.ts b/src/common/EventEmitter.ts index fb95ae9214..68eb60f7a5 100644 --- a/src/common/EventEmitter.ts +++ b/src/common/EventEmitter.ts @@ -75,6 +75,19 @@ export class EventEmitter extends Disposable implements IEventEmitter, IDisposab } } + public emitMayRemoveListeners(type: string, ...args: any[]): void { + if (!this._events[type]) { + return; + } + const obj = this._events[type]; + let length = obj.length; + for (let i = 0; i < obj.length; i++) { + obj[i].apply(this, args); + i -= length - obj.length; + length = obj.length; + } + } + public listeners(type: string): XtermListener[] { return this._events[type] || []; }