diff --git a/src/browser/Terminal.test.ts b/src/browser/Terminal.test.ts index 729c0d0f25..f192b03137 100644 --- a/src/browser/Terminal.test.ts +++ b/src/browser/Terminal.test.ts @@ -1484,6 +1484,54 @@ describe('Terminal', () => { assert.deepEqual(getLines(term, term.rows + 1), ['12345', '125', '125', '125', '125', '125']); }); }); + + describe('BS with reverseWraparound set/unset', () => { + const ttyBS = '\x08 \x08'; // tty ICANON sends on pressing BS + + beforeEach(() => { + term = new TestTerminal({cols: 5, rows: 5, scrollback: 1}); + }); + + describe('reverseWraparound set', () => { + it('should not reverse outside of scroll margins', () => { + // prepare buffer content + term.writeSync('#####abcdefghijklmnopqrstuvwxy'); + assert.deepEqual(getLines(term, 6), ['#####', 'abcde', 'fghij', 'klmno', 'pqrst', 'uvwxy']); + assert.equal(term.buffer.ydisp, 1); + assert.equal(term.buffer.x, 5); + assert.equal(term.buffer.y, 4); + term.writeSync(ttyBS.repeat(100)); + assert.deepEqual(getLines(term, 6), ['#####', 'abcde', 'fghij', 'klmno', 'pqrst', ' y']); + + term.writeSync('\x1b[?45h'); + term.writeSync('uvwxy'); + + // set top/bottom to 1/3 (0-based) + term.writeSync('\x1b[2;4r'); + // place cursor below scroll bottom + term.buffer.x = 5; + term.buffer.y = 4; + term.writeSync(ttyBS.repeat(100)); + assert.deepEqual(getLines(term, 6), ['#####', 'abcde', 'fghij', 'klmno', 'pqrst', ' ']); + + term.writeSync('uvwxy'); + // place cursor within scroll margins + term.buffer.x = 5; + term.buffer.y = 3; + term.writeSync(ttyBS.repeat(100)); + assert.deepEqual(getLines(term, 6), ['#####', 'abcde', ' ', ' ', ' ', 'uvwxy']); + assert.equal(term.buffer.x, 0); + assert.equal(term.buffer.y, term.buffer.scrollTop); // stops at 0, scrollTop + + term.writeSync('fghijklmnopqrst'); + // place cursor above scroll top + term.buffer.x = 5; + term.buffer.y = 0; + term.writeSync(ttyBS.repeat(100)); + assert.deepEqual(getLines(term, 6), ['#####', ' ', 'fghij', 'klmno', 'pqrst', 'uvwxy']); + }); + }); + }); }); }); diff --git a/src/browser/Terminal2.test.ts b/src/browser/Terminal2.test.ts index 32a47b2141..29d46dbfd7 100644 --- a/src/browser/Terminal2.test.ts +++ b/src/browser/Terminal2.test.ts @@ -19,7 +19,7 @@ const TESTFILES = glob.sync('**/escape_sequence_files/*.in', { cwd: path.join(__ const SKIP_FILES = [ 't0084-CBT.in', 't0101-NLM.in', - 't0103-reverse_wrap.in', + 't0103-reverse_wrap.in', // not comparable, we deviate from xterm reverse wrap on purpose 't0504-vim.in' ]; if (os.platform() === 'darwin') { diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts index 8f3ff9af43..6118859048 100644 --- a/src/common/InputHandler.test.ts +++ b/src/common/InputHandler.test.ts @@ -28,7 +28,10 @@ function getCursor(bufferService: IBufferService): number[] { function getLines(bufferService: IBufferService, limit: number = bufferService.rows): string[] { const res: string[] = []; for (let i = 0; i < limit; ++i) { - res.push(bufferService.buffer.lines.get(i)!.translateToString(true)); + const line = bufferService.buffer.lines.get(i); + if (line) { + res.push(line.translateToString(true)); + } } return res; } @@ -359,6 +362,14 @@ describe('InputHandler', () => { container[0] = 0x200B; inputHandler.print(container, 0, 1); }); + it('should clear cells to the right on early wrap-around', () => { + bufferService.resize(5, 5); + optionsService.options.scrollback = 1; + inputHandler.parse('12345'); + bufferService.buffer.x = 0; + inputHandler.parse('¥¥¥'); + assert.deepEqual(getLines(bufferService, 2), ['¥¥', '¥']); + }); }); describe('alt screen', () => { @@ -1375,6 +1386,88 @@ describe('InputHandler', () => { assert.deepEqual(getLines(bufferService), ['¥¥ ¥¥', '¥¥ ¥', '¥¥ ¥', '¥¥¥¥¥', '']); }); }); + + describe('BS with reverseWraparound set/unset', () => { + const ttyBS = '\x08 \x08'; // tty ICANON sends on pressing BS + beforeEach(() => { + bufferService.resize(5, 5); + optionsService.options.scrollback = 1; + }); + describe('reverseWraparound unset (default)', () => { + it('cannot delete last cell', () => { + inputHandler.parse('12345'); + inputHandler.parse(ttyBS); + assert.deepEqual(getLines(bufferService, 1), ['123 5']); + inputHandler.parse(ttyBS.repeat(10)); + assert.deepEqual(getLines(bufferService, 1), [' 5']); + }); + it('cannot access prev line', () => { + inputHandler.parse('12345'.repeat(2)); + inputHandler.parse(ttyBS); + assert.deepEqual(getLines(bufferService, 2), ['12345', '123 5']); + inputHandler.parse(ttyBS.repeat(10)); + assert.deepEqual(getLines(bufferService, 2), ['12345', ' 5']); + }); + }); + describe('reverseWraparound set', () => { + it('can delete last cell', () => { + inputHandler.parse('\x1b[?45h'); + inputHandler.parse('12345'); + inputHandler.parse(ttyBS); + assert.deepEqual(getLines(bufferService, 1), ['1234 ']); + inputHandler.parse(ttyBS.repeat(7)); + assert.deepEqual(getLines(bufferService, 1), [' ']); + }); + it('can access prev line if wrapped', () => { + inputHandler.parse('\x1b[?45h'); + inputHandler.parse('12345'.repeat(2)); + inputHandler.parse(ttyBS); + assert.deepEqual(getLines(bufferService, 2), ['12345', '1234 ']); + inputHandler.parse(ttyBS.repeat(7)); + assert.deepEqual(getLines(bufferService, 2), ['12 ', ' ']); + }); + it('should lift isWrapped', () => { + inputHandler.parse('\x1b[?45h'); + inputHandler.parse('12345'.repeat(2)); + assert.equal(bufferService.buffer.lines.get(1)?.isWrapped, true); + inputHandler.parse(ttyBS.repeat(7)); + assert.equal(bufferService.buffer.lines.get(1)?.isWrapped, false); + }); + it('stops at hard NLs', () => { + inputHandler.parse('\x1b[?45h'); + inputHandler.parse('12345\r\n'); + inputHandler.parse('12345'.repeat(2)); + inputHandler.parse(ttyBS.repeat(50)); + assert.deepEqual(getLines(bufferService, 3), ['12345', ' ', ' ']); + assert.equal(bufferService.buffer.x, 0); + assert.equal(bufferService.buffer.y, 1); + }); + it('handles wide chars correctly', () => { + inputHandler.parse('\x1b[?45h'); + inputHandler.parse('¥¥¥'); + assert.deepEqual(getLines(bufferService, 2), ['¥¥', '¥']); + inputHandler.parse(ttyBS); + assert.deepEqual(getLines(bufferService, 2), ['¥¥', ' ']); + assert.equal(bufferService.buffer.x, 1); + inputHandler.parse(ttyBS); + assert.deepEqual(getLines(bufferService, 2), ['¥¥', ' ']); + assert.equal(bufferService.buffer.x, 0); + inputHandler.parse(ttyBS); + assert.deepEqual(getLines(bufferService, 2), ['¥ ', ' ']); + assert.equal(bufferService.buffer.x, 3); // x=4 skipped due to early wrap-around + inputHandler.parse(ttyBS); + assert.deepEqual(getLines(bufferService, 2), ['¥ ', ' ']); + assert.equal(bufferService.buffer.x, 2); + inputHandler.parse(ttyBS); + assert.deepEqual(getLines(bufferService, 2), [' ', ' ']); + assert.equal(bufferService.buffer.x, 1); + inputHandler.parse(ttyBS); + assert.deepEqual(getLines(bufferService, 2), [' ', ' ']); + assert.equal(bufferService.buffer.x, 0); + }); + }); + }); + describe('DECSTR', () => { beforeEach(() => { bufferService.resize(10, 5); diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 1ec2a68db2..b66b038b8f 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -556,6 +556,10 @@ export class InputHandler extends Disposable implements IInputHandler { // autowrap - DECAWM // automatically wraps to the beginning of the next line if (wraparoundMode) { + // clear left over cells to the right + while (buffer.x < cols) { + bufferRow.setCellFromCodePoint(buffer.x++, 0, 1, curAttr.fg, curAttr.bg); + } buffer.x = 0; buffer.y++; if (buffer.y === buffer.scrollBottom + 1) { @@ -727,12 +731,62 @@ export class InputHandler extends Disposable implements IInputHandler { * Backspace (Ctrl-H). * * @vt: #Y C0 BS "Backspace" "\b, \x08" "Move the cursor one position to the left." + * By default it is not possible to move the cursor past the leftmost position. + * If `reverse wrap-around` (`CSI ? 45 h`) is set, a previous soft line wrap (DECAWM) + * can be undone with BS within the scroll margins. In that case the cursor will wrap back + * to the end of the previous row. Note that it is not possible to peek back into the scrollbuffer + * with the cursor, thus at the home position (top-leftmost cell) this has no effect. */ public backspace(): void { - this._restrictCursor(); - if (this._bufferService.buffer.x > 0) { - this._bufferService.buffer.x--; + const buffer = this._bufferService.buffer; + + // reverse wrap-around is disabled + if (!this._coreService.decPrivateModes.reverseWraparound) { + this._restrictCursor(); + if (buffer.x > 0) { + buffer.x--; + } + return; + } + + // reverse wrap-around is enabled + // other than for normal operation mode, reverse wrap-around allows the cursor + // to be at x=cols to be able to address the last cell of a row by BS + this._restrictCursor(this._bufferService.cols); + + if (buffer.x > 0) { + buffer.x--; + } else { + /** + * reverse wrap-around handling: + * Our implementation deviates from xterm on purpose. Details: + * - only previous soft NLs can be reversed (isWrapped=true) + * - only works within scrollborders (top/bottom, left/right not yet supported) + * - cannot peek into scrollbuffer + * - any cursor movement sequence keeps working as expected + */ + if (buffer.x === 0 + && buffer.y > buffer.scrollTop + && buffer.y <= buffer.scrollBottom + && buffer.lines.get(buffer.ybase + buffer.y)?.isWrapped) + { + buffer.lines.get(buffer.ybase + buffer.y)!.isWrapped = false; + buffer.y--; + buffer.x = this._bufferService.cols - 1; + // find last taken cell - last cell can have 3 different states: + // - hasContent(true) + hasWidth(1): narrow char - we are done + // - hasWidth(0): second part of wide char - we are done + // - hasContent(false) + hasWidth(1): empty cell due to early wrapping wide char, go one cell further back + const line = buffer.lines.get(buffer.ybase + buffer.y)!; + if (line.hasWidth(buffer.x) && !line.hasContent(buffer.x)) { + buffer.x--; + // We do this only once, since width=1 + hasContent=false currently happens only once before + // early wrapping of a wide char. + // This needs to be fixed once we support graphemes taking more than 2 cells. + } + } } + this._restrictCursor(); } /** @@ -777,8 +831,8 @@ export class InputHandler extends Disposable implements IInputHandler { /** * Restrict cursor to viewport size / scroll margin (origin mode). */ - private _restrictCursor(): void { - this._bufferService.buffer.x = Math.min(this._bufferService.cols - 1, Math.max(0, this._bufferService.buffer.x)); + private _restrictCursor(maxCol: number = this._bufferService.cols - 1): void { + this._bufferService.buffer.x = Math.min(maxCol, Math.max(0, this._bufferService.buffer.x)); this._bufferService.buffer.y = this._coreService.decPrivateModes.origin ? Math.min(this._bufferService.buffer.scrollBottom, Math.max(this._bufferService.buffer.scrollTop, this._bufferService.buffer.y)) : Math.min(this._bufferService.rows - 1, Math.max(0, this._bufferService.buffer.y)); @@ -1716,6 +1770,7 @@ export class InputHandler extends Disposable implements IInputHandler { * | 9 | X10 xterm mouse protocol. | #Y | * | 12 | Start Blinking Cursor. | #Y | * | 25 | Show Cursor (DECTCEM). | #Y | + * | 45 | Reverse wrap-around. | #Y | * | 47 | Use Alternate Screen Buffer. | #Y | * | 66 | Application keypad (DECNKM). | #Y | * | 1000 | X11 xterm mouse protocol. | #Y | @@ -1767,6 +1822,9 @@ export class InputHandler extends Disposable implements IInputHandler { case 12: // this.cursorBlink = true; break; + case 45: + this._coreService.decPrivateModes.reverseWraparound = true; + break; case 66: this._logService.debug('Serial port requested application keypad.'); this._coreService.decPrivateModes.applicationKeypad = true; @@ -1950,6 +2008,7 @@ export class InputHandler extends Disposable implements IInputHandler { * | 9 | Don't send Mouse X & Y on button press. | #Y | * | 12 | Stop Blinking Cursor. | #Y | * | 25 | Hide Cursor (DECTCEM). | #Y | + * | 45 | No reverse wrap-around. | #Y | * | 47 | Use Normal Screen Buffer. | #Y | * | 66 | Numeric keypad (DECNKM). | #Y | * | 1000 | Don't send Mouse reports. | #Y | @@ -1994,6 +2053,9 @@ export class InputHandler extends Disposable implements IInputHandler { case 12: // this.cursorBlink = false; break; + case 45: + this._coreService.decPrivateModes.reverseWraparound = false; + break; case 66: this._logService.debug('Switching back to normal keypad.'); this._coreService.decPrivateModes.applicationKeypad = false; diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index 71faed99ab..5580f05ab8 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -68,6 +68,7 @@ export class MockCoreService implements ICoreService { applicationKeypad: false, bracketedPasteMode: false, origin: false, + reverseWraparound: false, sendFocus: false, wraparound: true }; diff --git a/src/common/Types.d.ts b/src/common/Types.d.ts index 4fcb627bb8..a82cfc4eb5 100644 --- a/src/common/Types.d.ts +++ b/src/common/Types.d.ts @@ -175,6 +175,7 @@ export interface IDecPrivateModes { applicationKeypad: boolean; bracketedPasteMode: boolean; origin: boolean; + reverseWraparound: boolean; sendFocus: boolean; wraparound: boolean; // defaults: xterm - true, vt100 - false } diff --git a/src/common/services/CoreService.ts b/src/common/services/CoreService.ts index 55d425aa30..3ea4f29c23 100644 --- a/src/common/services/CoreService.ts +++ b/src/common/services/CoreService.ts @@ -17,6 +17,7 @@ const DEFAULT_DEC_PRIVATE_MODES: IDecPrivateModes = Object.freeze({ applicationKeypad: false, bracketedPasteMode: false, origin: false, + reverseWraparound: false, sendFocus: false, wraparound: true // defaults: xterm - true, vt100 - false });