diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2bcf782a..18d34831f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,9 +170,9 @@ jobs: strategy: matrix: node-version: [18] # just one as integration tests are about testing in browser - runs-on: [ubuntu] # macos is flaky + runs-on: [ubuntu-22.04] # macos is flaky browser: [chromium, firefox, webkit] - runs-on: ${{ matrix.runs-on }}-latest + runs-on: ${{ matrix.runs-on }} steps: - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }}.x diff --git a/addons/addon-search/src/SearchAddon.ts b/addons/addon-search/src/SearchAddon.ts index 79ed31ba5..77cf9b16a 100644 --- a/addons/addon-search/src/SearchAddon.ts +++ b/addons/addon-search/src/SearchAddon.ts @@ -499,12 +499,16 @@ export class SearchAddon extends Disposable implements ITerminalAddon , ISearchA const [stringLine, offsets] = cache; const offset = this._bufferColsToStringOffset(row, col); - const searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase(); - const searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase(); + let searchTerm = term; + let searchStringLine = stringLine; + if (!searchOptions.regex) { + searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase(); + searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase(); + } let resultIndex = -1; if (searchOptions.regex) { - const searchRegex = RegExp(searchTerm, 'g'); + const searchRegex = RegExp(searchTerm, searchOptions.caseSensitive ? 'g' : 'gi'); let foundTerm: RegExpExecArray | null; if (isReverseSearch) { // This loop will get the resultIndex of the _last_ regex match in the range 0..offset diff --git a/addons/addon-serialize/src/SerializeAddon.test.ts b/addons/addon-serialize/src/SerializeAddon.test.ts index 7cb071bff..0252b2015 100644 --- a/addons/addon-serialize/src/SerializeAddon.test.ts +++ b/addons/addon-serialize/src/SerializeAddon.test.ts @@ -222,7 +222,7 @@ describe('SerializeAddon', () => { it('empty terminal with default options', async () => { const output = serializeAddon.serializeAsHTML(); - assert.equal((output.match(/color: #000000; background-color: #ffffff; font-family: courier-new, courier, monospace; font-size: 15px;/g) || []).length, 1, output); + assert.equal((output.match(/color: #000000; background-color: #ffffff; font-family: monospace; font-size: 15px;/g) || []).length, 1, output); }); it('empty terminal with custom options', async () => { @@ -242,7 +242,7 @@ describe('SerializeAddon', () => { const output = serializeAddon.serializeAsHTML({ includeGlobalBackground: true }); - assert.equal((output.match(/color: #ffffff; background-color: #000000; font-family: courier-new, courier, monospace; font-size: 15px;/g) || []).length, 1, output); + assert.equal((output.match(/color: #ffffff; background-color: #000000; font-family: monospace; font-size: 15px;/g) || []).length, 1, output); }); it('cells with custom color styling', async () => { diff --git a/addons/addon-webgl/src/WebglRenderer.ts b/addons/addon-webgl/src/WebglRenderer.ts index 216b8404f..4be864c1b 100644 --- a/addons/addon-webgl/src/WebglRenderer.ts +++ b/addons/addon-webgl/src/WebglRenderer.ts @@ -33,6 +33,7 @@ export class WebglRenderer extends Disposable implements IRenderer { private _charAtlasDisposable = this._register(new MutableDisposable()); private _charAtlas: ITextureAtlas | undefined; private _devicePixelRatio: number; + private _deviceMaxTextureSize: number; private _observerDisposable = this._register(new MutableDisposable()); private _model: RenderModel = new RenderModel(); @@ -102,6 +103,8 @@ export class WebglRenderer extends Disposable implements IRenderer { throw new Error('WebGL2 not supported ' + this._gl); } + this._deviceMaxTextureSize = this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE); + this._register(addDisposableListener(this._canvas, 'webglcontextlost', (e) => { console.log('webglcontextlost event received'); // Prevent the default behavior in order to enable WebGL context restoration. @@ -272,7 +275,8 @@ export class WebglRenderer extends Disposable implements IRenderer { this.dimensions.device.cell.height, this.dimensions.device.char.width, this.dimensions.device.char.height, - this._coreBrowserService.dpr + this._coreBrowserService.dpr, + this._deviceMaxTextureSize ); if (this._charAtlas !== atlas) { this._onChangeTextureAtlas.fire(atlas.pages[0].canvas); @@ -354,7 +358,7 @@ export class WebglRenderer extends Disposable implements IRenderer { } private _updateCursorBlink(): void { - if (this._terminal.options.cursorBlink) { + if (this._coreService.decPrivateModes.cursorBlink ?? this._terminal.options.cursorBlink) { this._cursorBlinkStateManager.value = new CursorBlinkStateManager(() => { this._requestRedrawCursor(); }, this._coreBrowserService); @@ -377,8 +381,11 @@ export class WebglRenderer extends Disposable implements IRenderer { let line: IBufferLine; let joinedRanges: [number, number][]; let isJoined: boolean; + let skipJoinedCheckUntilX: number = 0; + let isValidJoinRange: boolean = true; let lastCharX: number; let range: [number, number]; + let isCursorRow: boolean; let chars: string; let code: number; let width: number; @@ -387,6 +394,7 @@ export class WebglRenderer extends Disposable implements IRenderer { let j: number; start = clamp(start, terminal.rows - 1, 0); end = clamp(end, terminal.rows - 1, 0); + const cursorStyle = this._coreService.decPrivateModes.cursorStyle ?? terminal.options.cursorStyle ?? 'block'; const cursorY = this._terminal.buffer.active.baseY + this._terminal.buffer.active.cursorY; const viewportRelativeCursorY = cursorY - terminal.buffer.ydisp; @@ -404,6 +412,8 @@ export class WebglRenderer extends Disposable implements IRenderer { row = y + terminal.buffer.ydisp; line = terminal.buffer.lines.get(row)!; this._model.lineLengths[y] = 0; + isCursorRow = cursorY === row; + skipJoinedCheckUntilX = 0; joinedRanges = this._characterJoinerService.getJoinedCharacters(row); for (x = 0; x < terminal.cols; x++) { lastBg = this._cellColorResolver.result.bg; @@ -415,25 +425,43 @@ export class WebglRenderer extends Disposable implements IRenderer { // If true, indicates that the current character(s) to draw were joined. isJoined = false; + + // Indicates whether this cell is part of a joined range that should be ignored as it cannot + // be rendered entirely, like the selection state differs across the range. + isValidJoinRange = (x >= skipJoinedCheckUntilX); + lastCharX = x; // Process any joined character ranges as needed. Because of how the // ranges are produced, we know that they are valid for the characters // and attributes of our input. - if (joinedRanges.length > 0 && x === joinedRanges[0][0]) { - isJoined = true; + if (joinedRanges.length > 0 && x === joinedRanges[0][0] && isValidJoinRange) { range = joinedRanges.shift()!; - // We already know the exact start and end column of the joined range, - // so we get the string and width representing it directly. - cell = new JoinedCellData( - cell, - line!.translateToString(true, range[0], range[1]), - range[1] - range[0] - ); + // If the ligature's selection state is not consistent, don't join it. This helps the + // selection render correctly regardless whether they should be joined. + const firstSelectionState = this._model.selection.isCellSelected(this._terminal, range[0], row); + for (i = range[0] + 1; i < range[1]; i++) { + isValidJoinRange &&= (firstSelectionState === this._model.selection.isCellSelected(this._terminal, i, row)); + } + // Similarly, if the cursor is in the ligature, don't join it. + isValidJoinRange &&= !isCursorRow || cursorX < range[0] || cursorX >= range[1]; + if (!isValidJoinRange) { + skipJoinedCheckUntilX = range[1]; + } else { + isJoined = true; - // Skip over the cells occupied by this range in the loop - lastCharX = range[1] - 1; + // We already know the exact start and end column of the joined range, + // so we get the string and width representing it directly. + cell = new JoinedCellData( + cell, + line!.translateToString(true, range[0], range[1]), + range[1] - range[0] + ); + + // Skip over the cells occupied by this range in the loop + lastCharX = range[1] - 1; + } } chars = cell.getChars(); @@ -450,8 +478,7 @@ export class WebglRenderer extends Disposable implements IRenderer { x: cursorX, y: viewportRelativeCursorY, width: cell.getWidth(), - style: this._coreBrowserService.isFocused ? - (terminal.options.cursorStyle || 'block') : terminal.options.cursorInactiveStyle, + style: this._coreBrowserService.isFocused ? cursorStyle : terminal.options.cursorInactiveStyle, cursorWidth: terminal.options.cursorWidth, dpr: this._devicePixelRatio }; @@ -459,9 +486,10 @@ export class WebglRenderer extends Disposable implements IRenderer { } if (x >= cursorX && x <= lastCursorX && ((this._coreBrowserService.isFocused && - (terminal.options.cursorStyle || 'block') === 'block') || + cursorStyle === 'block') || (this._coreBrowserService.isFocused === false && - terminal.options.cursorInactiveStyle === 'block'))) { + terminal.options.cursorInactiveStyle === 'block')) + ) { this._cellColorResolver.result.fg = Attributes.CM_RGB | (this._themeService.colors.cursorAccent.rgba >> 8 & Attributes.RGB_MASK); this._cellColorResolver.result.bg = diff --git a/addons/addon-webgl/src/renderLayer/BaseRenderLayer.ts b/addons/addon-webgl/src/renderLayer/BaseRenderLayer.ts index 3d9a1aa5c..0cac7eb3f 100644 --- a/addons/addon-webgl/src/renderLayer/BaseRenderLayer.ts +++ b/addons/addon-webgl/src/renderLayer/BaseRenderLayer.ts @@ -94,7 +94,8 @@ export abstract class BaseRenderLayer extends Disposable implements IRenderLayer if (this._deviceCharWidth <= 0 && this._deviceCharHeight <= 0) { return; } - this._charAtlas = acquireTextureAtlas(terminal, this._optionsService.rawOptions, colorSet, this._deviceCellWidth, this._deviceCellHeight, this._deviceCharWidth, this._deviceCharHeight, this._coreBrowserService.dpr); + + this._charAtlas = acquireTextureAtlas(terminal, this._optionsService.rawOptions, colorSet, this._deviceCellWidth, this._deviceCellHeight, this._deviceCharWidth, this._deviceCharHeight, this._coreBrowserService.dpr, 2048); this._charAtlas.warmUp(); } diff --git a/demo/client.ts b/demo/client.ts index 274431100..080270976 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -16,7 +16,7 @@ if ('WebAssembly' in window) { ImageAddon = imageAddon.ImageAddon; } -import { Terminal, ITerminalOptions, type IDisposable } from '@xterm/xterm'; +import { Terminal, ITerminalOptions, type IDisposable, type ITheme } from '@xterm/xterm'; import { AttachAddon } from '@xterm/addon-attach'; import { ClipboardAddon } from '@xterm/addon-clipboard'; import { FitAddon } from '@xterm/addon-fit'; @@ -136,7 +136,7 @@ const xtermjsTheme = { brightCyan: '#72F0FF', white: '#F8F8F8', brightWhite: '#FFFFFF' -}; +} satisfies ITheme; function setPadding(): void { term.element.style.padding = parseInt(paddingElement.value, 10).toString() + 'px'; addons.fit.instance.fit(); @@ -246,6 +246,7 @@ if (document.location.pathname === '/test') { document.getElementById('add-decoration').addEventListener('click', addDecoration); document.getElementById('add-overview-ruler').addEventListener('click', addOverviewRuler); document.getElementById('decoration-stress-test').addEventListener('click', decorationStressTest); + document.getElementById('ligatures-test').addEventListener('click', ligaturesTest); document.getElementById('weblinks-test').addEventListener('click', testWeblinks); document.getElementById('bce').addEventListener('click', coloredErase); addVtButtons(); @@ -268,7 +269,7 @@ function createTerminal(): void { backend: 'conpty', buildNumber: 22621 } : undefined, - fontFamily: '"Fira Code", courier-new, courier, monospace, "Powerline Extra Symbols"', + fontFamily: '"Fira Code", monospace, "Powerline Extra Symbols"', theme: xtermjsTheme } as ITerminalOptions); @@ -1275,9 +1276,9 @@ function addVtButtons(): void { const element = document.createElement('button'); element.textContent = name; - writeCsi.split(''); - const prefix = writeCsi.length === 2 ? writeCsi[0] : ''; - const suffix = writeCsi[writeCsi.length - 1]; + const writeCsiSplit = writeCsi.split('|'); + const prefix = writeCsiSplit.length === 2 ? writeCsiSplit[0] : ''; + const suffix = writeCsiSplit[writeCsiSplit.length - 1]; element.addEventListener(`click`, () => term.write(csi(`${prefix}${inputs.map(e => e.value).join(';')}${suffix}`))); const desc = document.createElement('span'); @@ -1290,22 +1291,23 @@ function addVtButtons(): void { } const vtFragment = document.createDocumentFragment(); const buttonSpecs: { [key: string]: { label: string, description: string, paramCount?: number }} = { - A: { label: 'CUU ↑', description: 'Cursor Up Ps Times' }, - B: { label: 'CUD ↓', description: 'Cursor Down Ps Times' }, - C: { label: 'CUF →', description: 'Cursor Forward Ps Times' }, - D: { label: 'CUB ←', description: 'Cursor Backward Ps Times' }, - E: { label: 'CNL', description: 'Cursor Next Line Ps Times' }, - F: { label: 'CPL', description: 'Cursor Preceding Line Ps Times' }, - G: { label: 'CHA', description: 'Cursor Character Absolute' }, - H: { label: 'CUP', description: 'Cursor Position [row;column]', paramCount: 2 }, - I: { label: 'CHT', description: 'Cursor Forward Tabulation Ps tab stops' }, - J: { label: 'ED', description: 'Erase in Display' }, - '?J': { label: 'DECSED', description: 'Erase in Display' }, - K: { label: 'EL', description: 'Erase in Line' }, - '?K': { label: 'DECSEL', description: 'Erase in Line' }, - L: { label: 'IL', description: 'Insert Ps Line(s)' }, - M: { label: 'DL', description: 'Delete Ps Line(s)' }, - P: { label: 'DCH', description: 'Delete Ps Character(s)' } + A: { label: 'CUU ↑', description: 'Cursor Up Ps Times' }, + B: { label: 'CUD ↓', description: 'Cursor Down Ps Times' }, + C: { label: 'CUF →', description: 'Cursor Forward Ps Times' }, + D: { label: 'CUB ←', description: 'Cursor Backward Ps Times' }, + E: { label: 'CNL', description: 'Cursor Next Line Ps Times' }, + F: { label: 'CPL', description: 'Cursor Preceding Line Ps Times' }, + G: { label: 'CHA', description: 'Cursor Character Absolute' }, + H: { label: 'CUP', description: 'Cursor Position [row;column]', paramCount: 2 }, + I: { label: 'CHT', description: 'Cursor Forward Tabulation Ps tab stops' }, + J: { label: 'ED', description: 'Erase in Display' }, + '?|J': { label: 'DECSED', description: 'Erase in Display' }, + K: { label: 'EL', description: 'Erase in Line' }, + '?|K': { label: 'DECSEL', description: 'Erase in Line' }, + L: { label: 'IL', description: 'Insert Ps Line(s)' }, + M: { label: 'DL', description: 'Delete Ps Line(s)' }, + P: { label: 'DCH', description: 'Delete Ps Character(s)' }, + ' q': { label: 'DECSCUSR', description: 'Set Cursor Style', paramCount: 1 } }; for (const s of Object.keys(buttonSpecs)) { const spec = buttonSpecs[s]; @@ -1315,6 +1317,20 @@ function addVtButtons(): void { document.querySelector('#vt-container').appendChild(vtFragment); } +function ligaturesTest(): void { + term.write([ + '', + '-<< -< -<- <-- <--- <<- <- -> ->> --> ---> ->- >- >>-', + '=<< =< =<= <== <=== <<= <= => =>> ==> ===> =>= >= >>=', + '<-> <--> <---> <----> <=> <==> <===> <====> :: ::: __', + '<~~ /> ~~> == != /= ~= <> === !== !=== =/= =!=', + '<: := *= *+ <* <*> *> <| <|> |> <. <.> .> +* =* =: :>', + '(* *) /* */ [| |] {| |} ++ +++ \/ /\ |- -|