diff --git a/addons/xterm-addon-serialize/src/SerializeAddon.ts b/addons/xterm-addon-serialize/src/SerializeAddon.ts index dad6aa4f25..0caaaa0395 100644 --- a/addons/xterm-addon-serialize/src/SerializeAddon.ts +++ b/addons/xterm-addon-serialize/src/SerializeAddon.ts @@ -21,7 +21,7 @@ abstract class BaseSerializeHandler { const cell2 = this._buffer.getNullCell(); let oldCell = cell1; - this._beforeSerialize(endRow - startRow); + this._beforeSerialize(endRow - startRow, startRow, endRow); for (let row = startRow; row < endRow; row++) { const line = this._buffer.getLine(row); @@ -36,7 +36,7 @@ abstract class BaseSerializeHandler { oldCell = c; } } - this._rowEnd(row); + this._rowEnd(row, row === endRow - 1); } this._afterSerialize(); @@ -45,8 +45,8 @@ abstract class BaseSerializeHandler { } protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void { } - protected _rowEnd(row: number): void { } - protected _beforeSerialize(rows: number): void { } + protected _rowEnd(row: number, isLastRow: boolean): void { } + protected _beforeSerialize(rows: number, startRow: number, endRow: number): void { } protected _afterSerialize(): void { } protected _serializeString(): string { return ''; } } @@ -71,27 +71,152 @@ function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean { && cell1.isDim() === cell2.isDim(); } + + class StringSerializeHandler extends BaseSerializeHandler { private _rowIndex: number = 0; private _allRows: string[] = new Array(); + private _allRowSeparators: string[] = new Array(); private _currentRow: string = ''; private _nullCellCount: number = 0; - constructor(buffer: IBuffer) { - super(buffer); + // we can see a full colored cell and a null cell that only have background the same style + // but the information isn't preserved by null cell itself + // so wee need to record it when required. + private _cursorStyle: IBufferCell = this._buffer1.getNullCell(); + + // where exact the cursor styles comes from + // because we can't copy the cell directly + // so we remember where the content comes from instead + private _cursorStyleRow: number = 0; + private _cursorStyleCol: number = 0; + + // this is a null cell for reference for checking whether background is empty or not + private _backgroundCell: IBufferCell = this._buffer1.getNullCell(); + + private _firstRow: number = 0; + private _lastCursorRow: number = 0; + private _lastCursorCol: number = 0; + private _lastContentCursorRow: number = 0; + private _lastContentCursorCol: number = 0; + + constructor(private _buffer1: IBuffer, private _terminal: Terminal) { + super(_buffer1); } - protected _beforeSerialize(rows: number): void { + protected _beforeSerialize(rows: number, start: number, end: number): void { this._allRows = new Array(rows); + this._lastContentCursorRow = start; + this._lastCursorRow = start; + this._firstRow = start; } - protected _rowEnd(row: number): void { - this._allRows[this._rowIndex++] = this._currentRow; + private _thisRowLastChar: IBufferCell = this._buffer1.getNullCell(); + private _thisRowLastSecondChar: IBufferCell = this._buffer1.getNullCell(); + private _nextRowFirstChar: IBufferCell = this._buffer1.getNullCell(); + protected _rowEnd(row: number, isLastRow: boolean): void { + // if there is colorful empty cell at line end, whe must pad it back, or the the color block will missing + if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) { + // use clear right to set background. + this._currentRow += `\x1b[${this._nullCellCount}X`; + } + + let rowSeparator = ''; + + // handle row separator + if (!isLastRow) { + // Enable BCE + if (row - this._firstRow >= this._terminal.rows) { + this._buffer1.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol, this._backgroundCell); + } + + // Fetch current line + const currentLine = this._buffer1.getLine(row)!; + // Fetch next line + const nextLine = this._buffer1.getLine(row + 1)!; + + if (!nextLine.isWrapped) { + // just insert the line break + rowSeparator = '\r\n'; + // we sended the enter + this._lastCursorRow = row + 1; + this._lastCursorCol = 0; + } else { + rowSeparator = ''; + const thisRowLastChar = currentLine.getCell(currentLine.length - 1, this._thisRowLastChar)!; + const thisRowLastSecondChar = currentLine.getCell(currentLine.length - 2, this._thisRowLastSecondChar)!; + const nextRowFirstChar = nextLine.getCell(0, this._nextRowFirstChar)!; + const isNextRowFirstCharDoubleWidth = nextRowFirstChar.getWidth() > 1; + + // validate whether this line wrap is ever possible + // which mean whether cursor can placed at a overflow position (x === row) naturally + let isValid = false; + + if ( + // you must output character to cause overflow, control sequence can't do this + nextRowFirstChar.getChars() && + isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0 + ) { + if ( + // the last character can't be null, + // you can't use control sequence to move cursor to (x === row) + (thisRowLastChar.getChars() || thisRowLastChar.getWidth() === 0) && + // change background of the first wrapped cell also affects BCE + // so we mark it as invalid to simply the process to determine line separator + equalBg(thisRowLastChar, nextRowFirstChar) + ) { + isValid = true; + } + + if ( + // the second to last character can't be null if the next line starts with CJK, + // you can't use control sequence to move cursor to (x === row) + isNextRowFirstCharDoubleWidth && + (thisRowLastSecondChar.getChars() || thisRowLastSecondChar.getWidth() === 0) && + // change background of the first wrapped cell also affects BCE + // so we mark it as invalid to simply the process to determine line separator + equalBg(thisRowLastChar, nextRowFirstChar) && + equalBg(thisRowLastSecondChar, nextRowFirstChar) + ) { + isValid = true; + } + } + + if (!isValid) { + // force the wrap with magic + // insert enough character to force the wrap + rowSeparator = '-'.repeat(this._nullCellCount + 1); + // move back and erase next line head + rowSeparator += '\x1b[1D\x1b[1X'; + + if (this._nullCellCount > 0) { + // do these because we filled the last several null slot, which we shouldn't + rowSeparator += '\x1b[A'; + rowSeparator += `\x1b[${currentLine.length - this._nullCellCount}C`; + rowSeparator += `\x1b[${this._nullCellCount}X`; + rowSeparator += `\x1b[${currentLine.length - this._nullCellCount}D`; + rowSeparator += '\x1b[B'; + } + + // This is content and need the be serialized even it is invisible. + // without this, wrap will be missing from outputs. + this._lastContentCursorRow = row + 1; + this._lastContentCursorCol = 0; + + // force commit the cursor position + this._lastCursorRow = row + 1; + this._lastCursorCol = 0; + } + } + } + + this._allRows[this._rowIndex] = this._currentRow; + this._allRowSeparators[this._rowIndex++] = rowSeparator; this._currentRow = ''; this._nullCellCount = 0; } - protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void { + private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): number[] { const sgrSeq: number[] = []; const fgChanged = !equalFg(cell, oldCell); const bgChanged = !equalBg(cell, oldCell); @@ -99,7 +224,9 @@ class StringSerializeHandler extends BaseSerializeHandler { if (fgChanged || bgChanged || flagsChanged) { if (cell.isAttributeDefault()) { - this._currentRow += '\x1b[0m'; + if (!oldCell.isAttributeDefault()) { + sgrSeq.push(0); + } } else { if (fgChanged) { const color = cell.getFgColor(); @@ -131,30 +258,129 @@ class StringSerializeHandler extends BaseSerializeHandler { } } - if (sgrSeq.length) { + return sgrSeq; + } + + protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void { + // a width 0 cell don't need to be count because it is just a placeholder after a CJK character; + const isPlaceHolderCell = cell.getWidth() === 0; + + if (isPlaceHolderCell) { + return; + } + + // this cell don't have content + const isEmptyCell = cell.getChars() === ''; + + const sgrSeq = this._diffStyle(cell, this._cursorStyle); + + // the empty cell style is only assumed to be changed when background changed, because foreground is always 0. + const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0; + + /** + * handles style change + */ + if (styleChanged) { + // before update the style, we need to fill empty cell back + if (this._nullCellCount > 0) { + // use clear right to set background. + if (!equalBg(this._cursorStyle, this._backgroundCell)) { + this._currentRow += `\x1b[${this._nullCellCount}X`; + } + // use move right to move cursor. + this._currentRow += `\x1b[${this._nullCellCount}C`; + this._nullCellCount = 0; + } + + this._lastContentCursorRow = this._lastCursorRow = row; + this._lastContentCursorCol = this._lastCursorCol = col; + this._currentRow += `\x1b[${sgrSeq.join(';')}m`; + + // update the last cursor style + const line = this._buffer1.getLine(row); + if (line !== undefined) { + line.getCell(col, this._cursorStyle); + this._cursorStyleRow = row; + this._cursorStyleCol = col; + } } - // Count number of null cells encountered after the last non-null cell and move the cursor - // if a non-null cell is found (eg. \t or cursor move) - if (cell.getChars() === '') { + /** + * handles actual content + */ + if (isEmptyCell) { this._nullCellCount += cell.getWidth(); - } else if (this._nullCellCount > 0) { - this._currentRow += `\x1b[${this._nullCellCount}C`; - this._nullCellCount = 0; - } + } else { + if (this._nullCellCount > 0) { + // we can just assume we have same style with previous one here + // because style change is handled by previous stage + // use move right when background is empty, use clear right when there is background. + if (equalBg(this._cursorStyle, this._backgroundCell)) { + this._currentRow += `\x1b[${this._nullCellCount}C`; + } else { + this._currentRow += `\x1b[${this._nullCellCount}X`; + this._currentRow += `\x1b[${this._nullCellCount}C`; + } + this._nullCellCount = 0; + } + + this._currentRow += cell.getChars(); - this._currentRow += cell.getChars(); + // update cursor + this._lastContentCursorRow = this._lastCursorRow = row; + this._lastContentCursorCol = this._lastCursorCol = col + cell.getWidth(); + } } protected _serializeString(): string { let rowEnd = this._allRows.length; - for (; rowEnd > 0; rowEnd--) { - if (this._allRows[rowEnd - 1]) { - break; + + // the fixup is only required for data without scrollback + // because it will always be placed at last line otherwise + if (this._buffer1.length - this._firstRow <= this._terminal.rows) { + rowEnd = this._lastContentCursorRow + 1 - this._firstRow; + this._lastCursorCol = this._lastContentCursorCol; + this._lastCursorRow = this._lastContentCursorRow; + } + + let content = ''; + + for (let i = 0; i < rowEnd; i++) { + content += this._allRows[i]; + if (i + 1 < rowEnd) { + content += this._allRowSeparators[i]; } } - return this._allRows.slice(0, rowEnd).join('\r\n'); + + // restore the cursor + const realCursorRow = this._buffer1.baseY + this._buffer1.cursorY; + const realCursorCol = this._buffer1.cursorX; + + const cursorMoved = (realCursorRow !== this._lastCursorRow || realCursorCol !== this._lastCursorCol); + + const moveRight = (offset: number): void => { + if (offset > 0) { + content += `\u001b[${offset}C`; + } else if (offset < 0) { + content += `\u001b[${-offset}D`; + } + }; + const moveDown = (offset: number): void => { + if (offset > 0) { + content += `\u001b[${offset}B`; + } else if (offset < 0) { + content += `\u001b[${-offset}A`; + } + }; + + if (cursorMoved) { + moveDown(realCursorRow - this._lastCursorRow); + moveRight(realCursorCol - this._lastCursorCol); + } + + + return content; } } @@ -167,20 +393,33 @@ export class SerializeAddon implements ITerminalAddon { this._terminal = terminal; } - public serialize(rows?: number): string { - // TODO: Add re-position cursor support - // TODO: Add word wrap mode support + private _getString(buffer: IBuffer, scrollback?: number): string { + const maxRows = buffer.length; + const handler = new StringSerializeHandler(buffer, this._terminal!); + + const correctRows = (scrollback === undefined) ? maxRows : constrain(scrollback + this!._terminal!.rows, 0, maxRows); + const result = handler.serialize(maxRows - correctRows, maxRows); + + return result; + } + + public serialize(scrollback?: number): string { // TODO: Add combinedData support if (!this._terminal) { throw new Error('Cannot use addon until it has been loaded'); } - const maxRows = this._terminal.buffer.active.length; - const handler = new StringSerializeHandler(this._terminal.buffer.active); + if (this._terminal.buffer.active.type === 'normal') { + return this._getString(this._terminal.buffer.active, scrollback); + } - rows = (rows === undefined) ? maxRows : constrain(rows, 0, maxRows); + const normalScreenContent = this._getString(this._terminal.buffer.normal, scrollback); + // alt screen don't have scrollback + const alternativeScreenContent = this._getString(this._terminal.buffer.alternate, undefined); - return handler.serialize(maxRows - rows, maxRows); + return normalScreenContent + + '\u001b[?1049h\u001b[H' + + alternativeScreenContent; } public dispose(): void { } diff --git a/addons/xterm-addon-serialize/test/SerializeAddon.api.ts b/addons/xterm-addon-serialize/test/SerializeAddon.api.ts index 5b615db1f4..47af9d91e4 100644 --- a/addons/xterm-addon-serialize/test/SerializeAddon.api.ts +++ b/addons/xterm-addon-serialize/test/SerializeAddon.api.ts @@ -14,6 +14,22 @@ let page: Page; const width = 800; const height = 600; +const writeRawSync = (page: any, str: string): Promise => writeSync(page, '\' +' + JSON.stringify(str) + '+ \''); + +const testNormalScreenEqual = async (page: any, str: string): Promise => { + await writeRawSync(page, str); + const originalBuffer = await page.evaluate(`inspectBuffer(term.buffer.normal);`); + + const result = await page.evaluate(`serializeAddon.serialize();`) as string; + await page.evaluate(`term.reset();`); + await writeRawSync(page, result); + const newBuffer = await page.evaluate(`inspectBuffer(term.buffer.normal);`); + + // chai decides -0 and 0 are different number... + // and firefox have a bug that output -0 for unknown reason + assert.equal(JSON.stringify(originalBuffer), JSON.stringify(newBuffer)); +}; + describe('SerializeAddon', () => { before(async function(): Promise { const browserType = getBrowserType(); @@ -27,19 +43,74 @@ describe('SerializeAddon', () => { await page.evaluate(` window.serializeAddon = new SerializeAddon(); window.term.loadAddon(window.serializeAddon); + window.inspectBuffer = (buffer) => { + const lines = []; + for (let i = 0; i < buffer.length; i++) { + // Do this intentionally to get content of underlining source + const bufferLine = buffer.getLine(i)._line; + lines.push(JSON.stringify(bufferLine)); + } + return { + x: buffer.cursorX, + y: buffer.cursorY, + data: lines + }; + } `); }); after(async () => await browser.close()); beforeEach(async () => await page.evaluate(`window.term.reset()`)); + it('produce different output when we call test util with different text', async function(): Promise { + await writeRawSync(page, '12345'); + const buffer1 = await page.evaluate(`inspectBuffer(term.buffer.normal);`); + + await page.evaluate(`term.reset();`); + await writeRawSync(page, '67890'); + const buffer2 = await page.evaluate(`inspectBuffer(term.buffer.normal);`); + + assert.throw(() => { + assert.equal(JSON.stringify(buffer1), JSON.stringify(buffer2)); + }); + }); + + it('produce different output when we call test util with different line wrap', async function(): Promise { + await writeRawSync(page, '1234567890\r\n12345'); + const buffer3 = await page.evaluate(`inspectBuffer(term.buffer.normal);`); + + await page.evaluate(`term.reset();`); + await writeRawSync(page, '1234567890n12345'); + const buffer4 = await page.evaluate(`inspectBuffer(term.buffer.normal);`); + + assert.throw(() => { + assert.equal(JSON.stringify(buffer3), JSON.stringify(buffer4)); + }); + }); + it('empty content', async function(): Promise { const rows = 10; const cols = 10; assert.equal(await page.evaluate(`serializeAddon.serialize();`), ''); }); - it('trim last empty lines', async function(): Promise { + it('unwrap wrapped line', async function(): Promise { + const lines = ['123456789123456789']; + await writeSync(page, lines.join('\\r\\n')); + assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n')); + }); + + it('does not unwrap non-wrapped line', async function(): Promise { + const lines = [ + '123456789', + '123456789' + ]; + await writeSync(page, lines.join('\\r\\n')); + assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n')); + }); + + + it('preserve last empty lines', async function(): Promise { const cols = 10; const lines = [ '', @@ -55,7 +126,7 @@ describe('SerializeAddon', () => { '' ]; await writeSync(page, lines.join('\\r\\n')); - assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.slice(0, 8).join('\r\n')); + assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n')); }); it('digits content', async function(): Promise { @@ -67,21 +138,22 @@ describe('SerializeAddon', () => { assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n')); }); - it('serialize half rows of content', async function(): Promise { - const rows = 10; - const halfRows = rows >> 1; + it('serialize with half of scrollback', async function(): Promise { + const rows = 20; + const scrollback = rows - 10; + const halfScrollback = scrollback / 2; const cols = 10; const lines = newArray((index: number) => digitsString(cols, index), rows); await writeSync(page, lines.join('\\r\\n')); - assert.equal(await page.evaluate(`serializeAddon.serialize(${halfRows});`), lines.slice(halfRows, 2 * halfRows).join('\r\n')); + assert.equal(await page.evaluate(`serializeAddon.serialize(${halfScrollback});`), lines.slice(halfScrollback, rows).join('\r\n')); }); - it('serialize 0 rows of content', async function(): Promise { - const rows = 10; + it('serialize 0 rows of scrollback', async function(): Promise { + const rows = 20; const cols = 10; const lines = newArray((index: number) => digitsString(cols, index), rows); await writeSync(page, lines.join('\\r\\n')); - assert.equal(await page.evaluate(`serializeAddon.serialize(0);`), ''); + assert.equal(await page.evaluate(`serializeAddon.serialize(0);`), lines.slice(rows - 10, rows).join('\r\n')); }); it('serialize all rows of content with color16', async function(): Promise { @@ -276,17 +348,15 @@ describe('SerializeAddon', () => { '中文中文', '12中文', '中文12', - '1中文中文中' // this line is going to be wrapped at last character because it has line length of 11 (1+2*5) - ]; - const expected = [ - '中文中文', - '12中文', - '中文12', - '1中文中文', - '中' + // This line is going to be wrapped at last character + // because it has line length of 11 (1+2*5). + // We concat it back without the null cell currently. + // But this may be incorrect. + // see also #3097 + '1中文中文中' ]; await writeSync(page, lines.join('\\r\\n')); - assert.equal(await page.evaluate(`serializeAddon.serialize();`), expected.join('\r\n')); + assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n')); }); it('serialize CJK Mixed with tab correctly', async () => { @@ -299,6 +369,118 @@ describe('SerializeAddon', () => { await writeSync(page, lines.join('\\r\\n')); assert.equal(await page.evaluate(`serializeAddon.serialize();`), expected.join('\r\n')); }); + + it('serialize with alt screen correctly', async () => { + const SMCUP = '\u001b[?1049h'; + const CUP = '\u001b[H'; + + const lines = [ + `1${SMCUP}${CUP}2` + ]; + const expected = [ + `1${SMCUP}${CUP}2` + ]; + + await writeSync(page, lines.join('\\r\\n')); + assert.equal(await page.evaluate(`window.term.buffer.active.type`), 'alternate'); + assert.equal(JSON.stringify(await page.evaluate(`serializeAddon.serialize();`)), JSON.stringify(expected.join('\r\n'))); + }); + + it('serialize without alt screen correctly', async () => { + const SMCUP = '\u001b[?1049h'; + const RMCUP = '\u001b[?1049l'; + + const lines = [ + `1${SMCUP}2${RMCUP}` + ]; + const expected = [ + `1` + ]; + + await writeSync(page, lines.join('\\r\\n')); + assert.equal(await page.evaluate(`window.term.buffer.active.type`), 'normal'); + assert.equal(JSON.stringify(await page.evaluate(`serializeAddon.serialize();`)), JSON.stringify(expected.join('\r\n'))); + }); + + it('serialize with background', async () => { + const CLEAR_RIGHT = (l: number): string => `\u001b[${l}X`; + + const lines = [ + `1\u001b[44m${CLEAR_RIGHT(5)}`, + `2${CLEAR_RIGHT(9)}` + ]; + + await testNormalScreenEqual(page, lines.join('\r\n')); + }); + + it('cause the BCE on scroll', async () => { + const CLEAR_RIGHT = (l: number): string => `\u001b[${l}X`; + + const padLines = newArray( + (index: number) => digitsString(10, index), + 10 + ); + + const lines = [ + ...padLines, + `\u001b[44m${CLEAR_RIGHT(5)}1111111111111111` + ]; + + await testNormalScreenEqual(page, lines.join('\r\n')); + }); + + it('handle invalid wrap before scroll', async () => { + const CLEAR_RIGHT = (l: number): string => `\u001b[${l}X`; + const MOVE_UP = (l: number): string => `\u001b[${l}A`; + const MOVE_DOWN = (l: number): string => `\u001b[${l}B`; + const MOVE_LEFT = (l: number): string => `\u001b[${l}D`; + + // A line wrap happened after current line. + // But there is no content. + // so wrap shouldn't even be able to happen. + const segments = [ + `123456789012345`, + MOVE_UP(1), + CLEAR_RIGHT(5), + MOVE_DOWN(1), + MOVE_LEFT(5), + CLEAR_RIGHT(5), + MOVE_UP(1), + '1' + ]; + + await testNormalScreenEqual(page, segments.join('')); + }); + + it('handle invalid wrap after scroll', async () => { + const CLEAR_RIGHT = (l: number): string => `\u001b[${l}X`; + const MOVE_UP = (l: number): string => `\u001b[${l}A`; + const MOVE_DOWN = (l: number): string => `\u001b[${l}B`; + const MOVE_LEFT = (l: number): string => `\u001b[${l}D`; + + const padLines = newArray( + (index: number) => digitsString(10, index), + 10 + ); + + // A line wrap happened after current line. + // But there is no content. + // so wrap shouldn't even be able to happen. + const lines = [ + padLines.join('\r\n'), + '\r\n', + `123456789012345`, + MOVE_UP(1), + CLEAR_RIGHT(5), + MOVE_DOWN(1), + MOVE_LEFT(5), + CLEAR_RIGHT(5), + MOVE_UP(1), + '1' + ]; + + await testNormalScreenEqual(page, lines.join('')); + }); }); function newArray(initial: T | ((index: number) => T), count: number): T[] {