diff --git a/addons/addon-clipboard/src/ClipboardAddon.ts b/addons/addon-clipboard/src/ClipboardAddon.ts index 5e194880c..535c450be 100644 --- a/addons/addon-clipboard/src/ClipboardAddon.ts +++ b/addons/addon-clipboard/src/ClipboardAddon.ts @@ -78,13 +78,43 @@ export class BrowserClipboardProvider implements IClipboardProvider { } } +/** + * TODO: Once the base64 handling on Uint8Arrays is more common, + * remove the btoa/atob fallbacks below. + */ +interface IUint8ArrayB64 extends Uint8Array { + toBase64(): string; +} +interface IUint8ArrayB64Ctor extends Uint8ArrayConstructor { + fromBase64(s: string): IUint8ArrayB64; +} + export class Base64 implements IBase64 { public encodeText(data: string): string { - return btoa(data); + const bytes = new TextEncoder().encode(data) as IUint8ArrayB64; + if (bytes.toBase64 !== undefined) { + return bytes.toBase64(); + } + let bin = ''; + for (let i = 0; i < bytes.length; i++) { + bin += String.fromCharCode(bytes[i]); + } + return btoa(bin); } public decodeText(data: string): string { + if ((Uint8Array as IUint8ArrayB64Ctor).fromBase64 !== undefined) { + try { + return new TextDecoder().decode((Uint8Array as IUint8ArrayB64Ctor).fromBase64(data)); + } catch {} + return ''; + } try { - return atob(data); + const bin = atob(data); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; ++i) { + bytes[i] = bin.charCodeAt(i); + } + return new TextDecoder().decode(bytes); } catch {} return ''; } diff --git a/addons/addon-clipboard/test/ClipboardAddon.test.ts b/addons/addon-clipboard/test/ClipboardAddon.test.ts index 2c04e619b..6995850d4 100644 --- a/addons/addon-clipboard/test/ClipboardAddon.test.ts +++ b/addons/addon-clipboard/test/ClipboardAddon.test.ts @@ -29,7 +29,7 @@ test.describe('ClipboardAddon', () => { await ctx.page.context().grantPermissions(['clipboard-read', 'clipboard-write']); } await ctx.page.evaluate(` - window.term.reset() + window.term.reset(); window.clipboard?.dispose(); window.clipboard = new ClipboardAddon(); window.term.loadAddon(window.clipboard); @@ -92,4 +92,22 @@ test.describe('ClipboardAddon', () => { deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), ''); }); }); + + test.describe('non-ASCII', () => { + const testDataEncoded = '4oKsbWzDpMO8dMOf'; + const testDataDecoded = '€mläütß'; + test('write simple string', async () => { + await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}\x07`); + deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded); + }); + test('read simple string', async () => { + await ctx.page.evaluate(` + window.data2 = []; + window.term.onData(e => data2.push(e)); + `); + await ctx.page.evaluate((d) => window.navigator.clipboard.writeText(d), testDataDecoded); + await ctx.proxy.write(`\x1b]52;c;?\x07`); + deepEqual(await ctx.page.evaluate('window.data2'), [`\x1b]52;c;${testDataEncoded}\x07`]); + }); + }); }); diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index 9d42ca9e1..1c3e08a1c 100644 --- a/addons/addon-webgl/src/TextureAtlas.ts +++ b/addons/addon-webgl/src/TextureAtlas.ts @@ -1080,17 +1080,33 @@ class AtlasPage { size: number, sourcePages?: AtlasPage[] ) { - if (sourcePages) { - for (const p of sourcePages) { - this._glyphs.push(...p.glyphs); - this._usedPixels += p._usedPixels; - } - } this.canvas = createCanvas(document, size, size); // The canvas needs alpha because we use clearColor to convert the background color to alpha. // It might also contain some characters with transparent backgrounds if allowTransparency is // set. this.ctx = throwIfFalsy(this.canvas.getContext('2d', { alpha: true })); + if (sourcePages) { + if (sourcePages.length === 4) { + // optimized for quadmerge + this._glyphs = this._glyphs.concat( + sourcePages[0].glyphs, + sourcePages[1].glyphs, + sourcePages[2].glyphs, + sourcePages[3].glyphs + ); + this._usedPixels = sourcePages[0]._usedPixels + + sourcePages[1]._usedPixels + + sourcePages[2]._usedPixels + + sourcePages[3]._usedPixels; + } else { + // fallback for non quadmerges (should never be used) + for (let i = 0; i < sourcePages.length; ++i) { + this._glyphs = this._glyphs.concat(sourcePages[i].glyphs); + this._usedPixels += sourcePages[i]._usedPixels; + } + + } + } } public clear(): void { diff --git a/addons/addon-webgl/src/WebglRenderer.ts b/addons/addon-webgl/src/WebglRenderer.ts index 2f38e03cb..a951efba5 100644 --- a/addons/addon-webgl/src/WebglRenderer.ts +++ b/addons/addon-webgl/src/WebglRenderer.ts @@ -456,7 +456,18 @@ export class WebglRenderer extends Disposable implements IRenderer { for (y = start; y <= end; y++) { row = y + terminal.buffer.ydisp; - line = terminal.buffer.lines.get(row)!; + const bufferLine = terminal.buffer.lines.get(row); + if (!bufferLine) { + this._model.lineLengths[y] = 0; + for (x = 0; x < terminal.cols; x++) { + j = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL; + modelUpdated = true; + this._nullModelCell(x, y, j, 0, 0, 0); + } + this._setRowBlinkState(y, false); + continue; + } + line = bufferLine; let rowHasBlinkingCells = false; this._model.lineLengths[y] = 0; isCursorRow = cursorY === row; @@ -587,13 +598,9 @@ export class WebglRenderer extends Disposable implements IRenderer { // Null out non-first cells for (x++; x <= lastCharX; x++) { j = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL; - this._glyphRenderer.value!.updateCell(x, y, NULL_CELL_CODE, 0, 0, 0, NULL_CELL_CHAR, 0, 0); - this._model.cells[j] = NULL_CELL_CODE; // Don't re-resolve the cell color since multi-colored ligature backgrounds are not // supported - this._model.cells[j + RenderModelConstants.BG_OFFSET] = this._cellColorResolver.result.bg; - this._model.cells[j + RenderModelConstants.FG_OFFSET] = this._cellColorResolver.result.fg; - this._model.cells[j + RenderModelConstants.EXT_OFFSET] = this._cellColorResolver.result.ext; + this._nullModelCell(x, y, j, this._cellColorResolver.result.bg, this._cellColorResolver.result.fg, this._cellColorResolver.result.ext); } x--; // Go back to the previous update cell for next iteration } @@ -607,6 +614,14 @@ export class WebglRenderer extends Disposable implements IRenderer { this._updateTextBlinkState(); } + private _nullModelCell(x: number, y: number, cellIndex: number, bg: number, fg: number, ext: number): void { + this._glyphRenderer.value!.updateCell(x, y, NULL_CELL_CODE, bg, fg, ext, NULL_CELL_CHAR, 0, 0); + this._model.cells[cellIndex] = NULL_CELL_CODE; + this._model.cells[cellIndex + RenderModelConstants.BG_OFFSET] = bg; + this._model.cells[cellIndex + RenderModelConstants.FG_OFFSET] = fg; + this._model.cells[cellIndex + RenderModelConstants.EXT_OFFSET] = ext; + } + private _resetBlinkingRowState(): void { this._rowHasBlinkingCells = new Array(this._terminal.rows).fill(false); this._rowHasBlinkingCellsCount = 0; diff --git a/demo/client/components/window/webglWindow.ts b/demo/client/components/window/webglWindow.ts index 2886c3b57..d7c175c45 100644 --- a/demo/client/components/window/webglWindow.ts +++ b/demo/client/components/window/webglWindow.ts @@ -11,8 +11,33 @@ export class WebglWindow extends BaseWindow implements IControlWindow { public readonly label = 'webgl'; private _textureAtlasContainer!: HTMLElement; + private _stressRunning = false; + private _stressScrolling: HTMLInputElement | undefined; public build(container: HTMLElement): void { + const stressDiv = document.createElement('div'); + const stressStart = document.createElement('button'); + stressStart.id = 'stress-start'; + stressStart.textContent = 'atlas stress start'; + stressStart.addEventListener('click', () => this._stress()); + stressDiv.appendChild(stressStart); + + const stressStop = document.createElement('button'); + stressStop.id = 'stress-stop'; + stressStop.textContent = 'atlas stress stop'; + stressStop.addEventListener('click', () => this._stressRunning = false); + stressDiv.appendChild(stressStop); + + this._stressScrolling = document.createElement('input'); + this._stressScrolling.type = 'checkbox'; + this._stressScrolling.id = 'stress-scrolling'; + stressDiv.appendChild(this._stressScrolling); + const stressScrollingLabel = document.createElement('label'); + stressScrollingLabel.htmlFor = 'stress-scrolling'; + stressScrollingLabel.textContent = 'stress scrolling'; + stressDiv.appendChild(stressScrollingLabel); + container.appendChild(stressDiv); + const zoomCheckbox = document.createElement('input'); zoomCheckbox.type = 'checkbox'; zoomCheckbox.id = 'texture-atlas-zoom'; @@ -48,4 +73,33 @@ export class WebglWindow extends BaseWindow implements IControlWindow { canvas.style.width = `${canvas.width / dpr}px`; canvas.style.height = `${canvas.height / dpr}px`; } + + private async _stress(): Promise { + const TEXT = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; + if (this._stressRunning) { + return; + } + this._stressRunning = true; + const scrolling = !!(this._stressScrolling?.checked); + for (let r = 0; r < 256; r += 2) { + for (let g = 0; g < 256; g += 2) { + let s: string[] = []; + for (let b = 0; b < 256; b += 2) { + if (!this._stressRunning) { + return; + } + const rbg = `RGB: ${[r, g, b]}`; + s.push(`\r\x1b[38;2;${r};${g};${b}m${rbg.padEnd(18, ' ')}${TEXT}`); + if (s.length >= 16) { + if (scrolling) { + await new Promise(r => this._terminal.write(s.join('\r\n') + '\r\n', r)); + } else { + await new Promise(r => this._terminal.write('\x1b[H' + s.join('\r\n'), r)); + } + s = []; + } + } + } + } + } } diff --git a/src/browser/renderer/dom/DomRenderer.ts b/src/browser/renderer/dom/DomRenderer.ts index 383cf3025..657217f71 100644 --- a/src/browser/renderer/dom/DomRenderer.ts +++ b/src/browser/renderer/dom/DomRenderer.ts @@ -536,9 +536,14 @@ export class DomRenderer extends Disposable implements IRenderer { for (let y = start; y <= end; y++) { const row = y + buffer.ydisp; const rowElement = this._rowElements[y]; + if (!rowElement) { + continue; + } const lineData = buffer.lines.get(row); - if (!rowElement || !lineData) { - break; + if (!lineData) { + rowElement.replaceChildren(); + this._setRowBlinkState(y, false); + continue; } rowElement.replaceChildren( ...this._rowFactory.createRow( @@ -610,9 +615,14 @@ export class DomRenderer extends Disposable implements IRenderer { for (let i = y; i <= y2; ++i) { const row = i + buffer.ydisp; const rowElement = this._rowElements[i]; + if (!rowElement) { + continue; + } const bufferline = buffer.lines.get(row); - if (!rowElement || !bufferline) { - break; + if (!bufferline) { + rowElement.replaceChildren(); + this._setRowBlinkState(i, false); + continue; } rowElement.replaceChildren( ...this._rowFactory.createRow( diff --git a/src/browser/selection/SelectionModel.test.ts b/src/browser/selection/SelectionModel.test.ts index 5f813d7ee..10ca79a34 100644 --- a/src/browser/selection/SelectionModel.test.ts +++ b/src/browser/selection/SelectionModel.test.ts @@ -64,6 +64,13 @@ describe('SelectionModel', () => { assert.deepEqual(model.finalSelectionStart, undefined); assert.deepEqual(model.finalSelectionEnd, undefined); }); + it('should reset selection start to origin when start row is trimmed', () => { + model.selectionStart = [50, 0]; + model.selectionEnd = [10, 2]; + assert.equal(model.handleTrim(1), true); + assert.deepEqual(model.finalSelectionStart, [0, 0]); + assert.deepEqual(model.finalSelectionEnd, [10, 1]); + }); }); describe('finalSelectionStart', () => { diff --git a/src/browser/selection/SelectionModel.ts b/src/browser/selection/SelectionModel.ts index 7b62b230b..8f87f17e2 100644 --- a/src/browser/selection/SelectionModel.ts +++ b/src/browser/selection/SelectionModel.ts @@ -135,9 +135,10 @@ export class SelectionModel { return true; } - // If the selection start is trimmed, ensure the start column is 0. + // If the selection start row is trimmed away, reset to the buffer origin. if (this.selectionStart && this.selectionStart[1] < 0) { - this.selectionStart[1] = 0; + this.selectionStart = [0, 0]; + return true; } return false; } diff --git a/src/browser/services/SelectionService.ts b/src/browser/services/SelectionService.ts index 576bdc525..39d8dd76b 100644 --- a/src/browser/services/SelectionService.ts +++ b/src/browser/services/SelectionService.ts @@ -704,7 +704,7 @@ export class SelectionService extends Disposable implements ISelectionService { if (this._activeSelectionMode !== SelectionMode.COLUMN) { this._model.selectionEnd[0] = this._bufferService.cols; } - this._model.selectionEnd[1] = Math.min(buffer.ydisp + this._bufferService.rows, buffer.lines.length - 1); + this._model.selectionEnd[1] = Math.min(buffer.ydisp + this._bufferService.rows - 1, buffer.lines.length - 1); } else { if (this._activeSelectionMode !== SelectionMode.COLUMN) { this._model.selectionEnd[0] = 0; diff --git a/src/common/input/TextDecoder.test.ts b/src/common/input/TextDecoder.test.ts index b32d8a721..0276fc8c8 100644 --- a/src/common/input/TextDecoder.test.ts +++ b/src/common/input/TextDecoder.test.ts @@ -79,7 +79,7 @@ function assertDecodedRange( } const target = new Uint32Array(count); const length = decode(input, target); - assert.equal(length, count); + assert.strictEqual(length, count); let mismatchIndex = -1; let index = 0; for (let i = min; i < max; ++i) { @@ -92,8 +92,8 @@ function assertDecodedRange( } index++; } - assert.equal(mismatchIndex, -1); - assert.equal(outputToString(target, length), input); + assert.strictEqual(mismatchIndex, -1); + assert.strictEqual(outputToString(target, length), input); } const BATCH_SIZE = 8192; @@ -116,9 +116,9 @@ describe('text encodings', () => { const data = new Uint32Array(s.length); for (let i = 0; i < s.length; ++i) { data[i] = s.charCodeAt(i); - assert.equal(stringFromCodePoint(data[i]), s[i]); + assert.strictEqual(stringFromCodePoint(data[i]), s[i]); } - assert.equal(utf32ToString(data), s); + assert.strictEqual(utf32ToString(data), s); }); describe('StringToUtf32 decoder', () => { @@ -159,7 +159,7 @@ describe('text encodings', () => { const decoder = new StringToUtf32(); const target = new Uint32Array(5); const length = decoder.decode(String.fromCharCode(0xFEFF), target); - assert.equal(length, 0); + assert.strictEqual(length, 0); decoder.clear(); }); }); @@ -169,7 +169,7 @@ describe('text encodings', () => { const target = new Uint32Array(500); for (let i = 0; i < TEST_STRINGS.length; ++i) { const length = decoder.decode(TEST_STRINGS[i], target); - assert.equal(toString(target, length), TEST_STRINGS[i]); + assert.strictEqual(toString(target, length), TEST_STRINGS[i]); decoder.clear(); } }); @@ -184,7 +184,7 @@ describe('text encodings', () => { const written = decoder.decode(input[i], target); decoded += toString(target, written); } - assert(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€'); + assert.strictEqual(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€'); }); }); }); @@ -226,7 +226,7 @@ describe('text encodings', () => { const target = new Uint32Array(5); const utf8Data = stringToUtf8Bytes(String.fromCharCode(0xFEFF)); const length = decoder.decode(utf8Data, target); - assert.equal(length, 0); + assert.strictEqual(length, 0); decoder.clear(); }); }); @@ -237,7 +237,7 @@ describe('text encodings', () => { for (let i = 0; i < TEST_STRINGS.length; ++i) { const utf8Data = stringToUtf8Bytes(TEST_STRINGS[i]); const length = decoder.decode(utf8Data, target); - assert.equal(toString(target, length), TEST_STRINGS[i]); + assert.strictEqual(toString(target, length), TEST_STRINGS[i]); decoder.clear(); } }); @@ -252,7 +252,7 @@ describe('text encodings', () => { const written = decoder.decode(utf8Data.slice(i, i + 1), target); decoded += toString(target, written); } - assert(decoded, 'ÄÖÜßöäü'); + assert.strictEqual(decoded, 'ÄÖÜßöäü'); }); it('2/3 byte sequences - advance by 1', () => { @@ -264,7 +264,7 @@ describe('text encodings', () => { const written = decoder.decode(utf8Data.slice(i, i + 1), target); decoded += toString(target, written); } - assert(decoded, 'Āր܀߀ö€ä€ü'); + assert.strictEqual(decoded, 'Āր܀߀ö€ä€ü'); }); it('2/3/4 byte sequences - advance by 1', () => { @@ -276,7 +276,7 @@ describe('text encodings', () => { const written = decoder.decode(utf8Data.slice(i, i + 1), target); decoded += toString(target, written); } - assert(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€'); + assert.strictEqual(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€'); }); it('2/3/4 byte sequences - advance by 2', () => { @@ -288,7 +288,7 @@ describe('text encodings', () => { const written = decoder.decode(utf8Data.slice(i, i + 2), target); decoded += toString(target, written); } - assert(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€'); + assert.strictEqual(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€'); }); it('2/3/4 byte sequences - advance by 3', () => { @@ -300,7 +300,7 @@ describe('text encodings', () => { const written = decoder.decode(utf8Data.slice(i, i + 3), target); decoded += toString(target, written); } - assert(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€'); + assert.strictEqual(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€'); }); it('BOMs (3 byte sequences) - advance by 2', () => { @@ -312,7 +312,7 @@ describe('text encodings', () => { const written = decoder.decode(utf8Data.slice(i, i + 2), target); decoded += toString(target, written); } - assert.equal(decoded, ''); + assert.strictEqual(decoded, ''); }); it('test break after 3 bytes - issue #2495', () => { @@ -320,11 +320,37 @@ describe('text encodings', () => { const target = new Uint32Array(5); const utf8Data = fromByteString('\xf0\xa0\x9c\x8e'); let written = decoder.decode(utf8Data.slice(0, 3), target); - assert.equal(written, 0); + assert.strictEqual(written, 0); written = decoder.decode(utf8Data.slice(3), target); - assert.equal(written, 1); - assert(toString(target, written), '𠜎'); + assert.strictEqual(written, 1); + assert.strictEqual(toString(target, written), '𠜎'); }); + + describe('0x80 not swallowed in continuation', () => { + it('A—B', () => { + const decoder = new Utf8ToUtf32(); + const target = new Uint32Array(5); + const utf8Data = new TextEncoder().encode('A—BA—BA—BA—BA—B'); + let decoded = ''; + for (let i = 0; i < utf8Data.length; i += 2) { + const written = decoder.decode(utf8Data.slice(i, i + 2), target); + decoded += toString(target, written); + } + assert.strictEqual(decoded, 'A—BA—BA—BA—BA—B'); + }); + it('A𐀀B', () => { + const decoder = new Utf8ToUtf32(); + const target = new Uint32Array(5); + const utf8Data = new TextEncoder().encode('A𐀀BA𐀀BA𐀀BA𐀀BA𐀀B'); + let decoded = ''; + for (let i = 0; i < utf8Data.length; i += 2) { + const written = decoder.decode(utf8Data.slice(i, i + 2), target); + decoded += toString(target, written); + } + assert.strictEqual(decoded, 'A𐀀BA𐀀BA𐀀BA𐀀BA𐀀B'); + }); + }); + }); }); }); diff --git a/src/common/input/TextDecoder.ts b/src/common/input/TextDecoder.ts index ac7f8d155..7e0495630 100644 --- a/src/common/input/TextDecoder.ts +++ b/src/common/input/TextDecoder.ts @@ -158,9 +158,9 @@ export class Utf8ToUtf32 { cp &= ((((cp & 0xE0) === 0xC0)) ? 0x1F : (((cp & 0xF0) === 0xE0)) ? 0x0F : 0x07); let pos = 0; let tmp: number; - while ((tmp = this.interim[++pos] & 0x3F) && pos < 4) { + while ((tmp = this.interim[++pos]) && pos < 4) { cp <<= 6; - cp |= tmp; + cp |= tmp & 0x3F; } // missing bytes - read ahead from input const type = (((this.interim[0] & 0xE0) === 0xC0)) ? 2 : (((this.interim[0] & 0xF0) === 0xE0)) ? 3 : 4;