diff --git a/addons/addon-fit/src/FitAddon.ts b/addons/addon-fit/src/FitAddon.ts index 3b06fb73..a1ef53e7 100644 --- a/addons/addon-fit/src/FitAddon.ts +++ b/addons/addon-fit/src/FitAddon.ts @@ -69,9 +69,10 @@ export class FitAddon implements ITerminalAddon, IFitApi { return undefined; } - const scrollbarWidth = (this._terminal.options.scrollback === 0 + const showScrollbar = this._terminal.options.scrollbar?.showScrollbar ?? true; + const scrollbarWidth = (this._terminal.options.scrollback === 0 || !showScrollbar ? 0 - : (this._terminal.options.overviewRuler?.width || ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH)); + : (this._terminal.options.scrollbar?.width ?? ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH)); const parentElementStyle = _getComputedStyle(this._terminal.element.parentElement); const parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height')); diff --git a/addons/addon-image/src/IIPHandler.ts b/addons/addon-image/src/IIPHandler.ts index c0d28c00..9662303f 100644 --- a/addons/addon-image/src/IIPHandler.ts +++ b/addons/addon-image/src/IIPHandler.ts @@ -5,16 +5,19 @@ import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types'; import { ImageRenderer } from './ImageRenderer'; import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage'; -import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm'; +import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm'; import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser'; import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics'; -// limit hold memory in base64 decoder (encoded bytes) -const KEEP_DATA = 4194304; -const INITIAL_DATA = 1048576; - -// Local mirror of const enum (esbuild can't inline const enums from external packages) -const DECODER_OK: DecodeStatus.OK = 0; +// Local const enum mirror - esbuild can't inline const enums from external packages +const enum DecoderConst { + // Limit held memory in base64 decoder (encoded bytes). + KEEP_DATA = 4194304, + // Initial buffer allocation for the decoder. + INITIAL_DATA = 1048576, + // Local mirror of const enum (esbuild can't inline const enums from external packages) + OK = 0 +} // default IIP header values const DEFAULT_HEADER: IHeaderFields = { @@ -41,8 +44,8 @@ export class IIPHandler implements IOscHandler, IResetHandler { private readonly _coreTerminal: ITerminalExt ) { const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3); - const initialBytes = Math.min(INITIAL_DATA, maxEncodedBytes); - this._dec = new Base64Decoder(KEEP_DATA, maxEncodedBytes, initialBytes); + const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes); + this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes); } public reset(): void {} @@ -58,7 +61,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { if (this._aborted) return; if (this._hp.state === HeaderState.END) { - if (this._dec.put(data.subarray(start, end)) !== DECODER_OK) { + if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) { this._dec.release(); this._aborted = true; } @@ -75,7 +78,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { return; } this._dec.init(); - if (this._dec.put(data.subarray(dataPos, end)) !== DECODER_OK) { + if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) { this._dec.release(); this._aborted = true; } diff --git a/addons/addon-serialize/src/SerializeAddon.test.ts b/addons/addon-serialize/src/SerializeAddon.test.ts index 6e8c2ad4..4f4fd546 100644 --- a/addons/addon-serialize/src/SerializeAddon.test.ts +++ b/addons/addon-serialize/src/SerializeAddon.test.ts @@ -116,7 +116,7 @@ describe('SerializeAddon', () => { describe('underline styles', () => { it('should serialize single underline with style', async () => { await writeP(terminal, sgr('4:1') + 'test' + sgr('24')); - assert.equal(serializeAddon.serialize(), '\u001b[4:1mtest\u001b[0m'); + assert.equal(serializeAddon.serialize(), '\u001b[4mtest\u001b[0m'); }); it('should serialize double underline', async () => { diff --git a/addons/addon-serialize/src/SerializeAddon.ts b/addons/addon-serialize/src/SerializeAddon.ts index fadeb5bb..4980c8ed 100644 --- a/addons/addon-serialize/src/SerializeAddon.ts +++ b/addons/addon-serialize/src/SerializeAddon.ts @@ -89,11 +89,19 @@ function equalUnderline(cell1: IBufferCell | IAttributeData, cell2: IBufferCell) if (!cell1.isUnderline() && !cell2.isUnderline()) { return true; } - const cell1Data = cell1 as unknown as IAttributeData; - const cell2Data = cell2 as unknown as IAttributeData; - return cell1Data.getUnderlineStyle() === cell2Data.getUnderlineStyle() - && cell1Data.getUnderlineColor() === cell2Data.getUnderlineColor() - && cell1Data.getUnderlineColorMode() === cell2Data.getUnderlineColorMode(); + if (cell1.getUnderlineStyle() !== cell2.getUnderlineStyle()) { + return false; + } + const cell1Default = cell1.isUnderlineColorDefault(); + const cell2Default = cell2.isUnderlineColorDefault(); + if (cell1Default && cell2Default) { + return true; + } + if (cell1Default !== cell2Default) { + return false; + } + return cell1.getUnderlineColor() === cell2.getUnderlineColor() + && cell1.getUnderlineColorMode() === cell2.getUnderlineColorMode(); } function equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean { @@ -109,6 +117,16 @@ function equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): bo && cell1.isStrikethrough() === cell2.isStrikethrough(); } +function attributesEquals(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean { + const cell1AsBufferCell = cell1 as IBufferCell; + if (typeof cell1AsBufferCell.attributesEquals === 'function') { + return cell1AsBufferCell.attributesEquals(cell2); + } + return equalFg(cell1, cell2) + && equalBg(cell1, cell2) + && equalFlags(cell1, cell2); +} + class StringSerializeHandler extends BaseSerializeHandler { private _rowIndex: number = 0; private _allRows: string[] = new Array(); @@ -258,6 +276,9 @@ class StringSerializeHandler extends BaseSerializeHandler { private _diffStyle(cell: IBufferCell | IAttributeData, oldCell: IBufferCell): number[] { const sgrSeq: number[] = []; + if (attributesEquals(cell, oldCell)) { + return sgrSeq; + } const fgChanged = !equalFg(cell, oldCell); const bgChanged = !equalBg(cell, oldCell); const flagsChanged = !equalFlags(cell, oldCell); @@ -290,17 +311,18 @@ class StringSerializeHandler extends BaseSerializeHandler { if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); } if (cell.isBold() !== oldCell.isBold()) { sgrSeq.push(cell.isBold() ? 1 : 22); } if (!equalUnderline(cell, oldCell)) { - const cellData = cell as unknown as IAttributeData; - const style = cellData.getUnderlineStyle(); + const style = cell.getUnderlineStyle(); if (style === UnderlineStyle.NONE) { sgrSeq.push(24); + } else if (style === UnderlineStyle.SINGLE && cell.isUnderlineColorDefault()) { + sgrSeq.push(4); } else { // Use SGR 4:X format for underline styles sgrSeq.push('4:' + style as unknown as number); // Handle underline color - if (!cellData.isUnderlineColorDefault()) { - const color = cellData.getUnderlineColor(); - if (cellData.isUnderlineColorRGB()) { + if (!cell.isUnderlineColorDefault()) { + const color = cell.getUnderlineColor(); + if (cell.isUnderlineColorRGB()) { sgrSeq.push('58:2::' + ((color >>> 16) & 0xFF) + ':' + ((color >>> 8) & 0xFF) + ':' + (color & 0xFF) as unknown as number); } else { sgrSeq.push('58:5:' + color as unknown as number); @@ -675,12 +697,11 @@ export class HTMLSerializeHandler extends BaseSerializeHandler { } private _getUnderlineColor(cell: IBufferCell): string | undefined { - const cellData = cell as unknown as IAttributeData; - if (cellData.isUnderlineColorDefault()) { + if (cell.isUnderlineColorDefault()) { return undefined; } - const color = cellData.getUnderlineColor(); - if (cellData.isUnderlineColorRGB()) { + const color = cell.getUnderlineColor(); + if (cell.isUnderlineColorRGB()) { const rgb = [ (color >> 16) & 255, (color >> 8) & 255, @@ -693,8 +714,7 @@ export class HTMLSerializeHandler extends BaseSerializeHandler { } private _getUnderlineStyle(cell: IBufferCell): string { - const cellData = cell as unknown as IAttributeData; - switch (cellData.getUnderlineStyle()) { + switch (cell.getUnderlineStyle()) { case UnderlineStyle.SINGLE: return 'underline'; case UnderlineStyle.DOUBLE: @@ -713,6 +733,10 @@ export class HTMLSerializeHandler extends BaseSerializeHandler { private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): string[] | undefined { const content: string[] = []; + if (attributesEquals(cell, oldCell)) { + return undefined; + } + const fgChanged = !equalFg(cell, oldCell); const bgChanged = !equalBg(cell, oldCell); const flagsChanged = !equalFlags(cell, oldCell); diff --git a/addons/addon-serialize/test/SerializeAddon.test.ts b/addons/addon-serialize/test/SerializeAddon.test.ts index cf00b391..ee5355fa 100644 --- a/addons/addon-serialize/test/SerializeAddon.test.ts +++ b/addons/addon-serialize/test/SerializeAddon.test.ts @@ -203,6 +203,46 @@ test.describe('SerializeAddon', () => { strictEqual(await ctx.page.evaluate(`window.serialize.serialize();`), lines.join('\r\n')); }); + test('buffer cell attributesEquals compares underline style and color', async () => { + await ctx.proxy.write(`${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}A${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}B${sgr(NORMAL)}`); + const sameAttributes = await ctx.page.evaluate(`(() => { + const line = window.term.buffer.active.getLine(0); + const cellA = line?.getCell(0); + const cellB = line?.getCell(1); + if (!cellA || !cellB) { + return undefined; + } + return cellA.attributesEquals(cellB); + })()`); + strictEqual(sameAttributes, true); + + await ctx.page.evaluate(`window.term.reset()`); + await ctx.proxy.write(`${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}A${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_GREEN)}B${sgr(NORMAL)}`); + const differentColor = await ctx.page.evaluate(`(() => { + const line = window.term.buffer.active.getLine(0); + const cellA = line?.getCell(0); + const cellB = line?.getCell(1); + if (!cellA || !cellB) { + return undefined; + } + return cellA.attributesEquals(cellB); + })()`); + strictEqual(differentColor, false); + + await ctx.page.evaluate(`window.term.reset()`); + await ctx.proxy.write(`${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}A${sgr(UNDERLINED, UNDERLINE_COLOR_RED)}B${sgr(NORMAL)}`); + const differentStyle = await ctx.page.evaluate(`(() => { + const line = window.term.buffer.active.getLine(0); + const cellA = line?.getCell(0); + const cellB = line?.getCell(1); + if (!cellA || !cellB) { + return undefined; + } + return cellA.attributesEquals(cellB); + })()`); + strictEqual(differentStyle, false); + }); + test('serialize all rows of content with color256', async function(): Promise { const rows = 32; const cols = 10; @@ -602,6 +642,9 @@ const BOLD = '1'; const DIM = '2'; const ITALIC = '3'; const UNDERLINED = '4'; +const UNDERLINE_DOUBLE = '4:2'; +const UNDERLINE_COLOR_RED = '58;5;196'; +const UNDERLINE_COLOR_GREEN = '58;5;46'; const BLINK = '5'; const INVERSE = '7'; const INVISIBLE = '8'; diff --git a/addons/addon-webgl/src/CellColorResolver.ts b/addons/addon-webgl/src/CellColorResolver.ts index 6f61a704..3a4661cc 100644 --- a/addons/addon-webgl/src/CellColorResolver.ts +++ b/addons/addon-webgl/src/CellColorResolver.ts @@ -7,6 +7,7 @@ import { ICellData } from 'common/Types'; import { Terminal } from '@xterm/xterm'; import { rgba } from 'common/Color'; import { treatGlyphAsBackgroundColor } from 'browser/renderer/shared/RendererUtils'; +import { blockPatternCodepoints } from './customGlyphs/CustomGlyphDefinitions'; // Work variables to avoid garbage collection let $fg = 0; @@ -42,7 +43,7 @@ export class CellColorResolver { * Resolves colors for the cell, putting the result into the shared {@link result}. This resolves * overrides, inverse and selection for the cell which can then be used to feed into the renderer. */ - public resolve(cell: ICellData, x: number, y: number, deviceCellWidth: number): void { + public resolve(cell: ICellData, x: number, y: number, deviceCellWidth: number, deviceCellHeight: number): void { this.result.bg = cell.bg; this.result.fg = cell.fg; this.result.ext = cell.bg & BgFlags.HAS_EXTENDED ? cell.extended.ext : 0; @@ -63,7 +64,9 @@ export class CellColorResolver { const lineWidth = Math.max(1, Math.floor(this._optionService.rawOptions.fontSize * this._coreBrowserService.dpr / 15)); $variantOffset = x * deviceCellWidth % (Math.round(lineWidth) * 2); } - + if ($variantOffset === 0 && blockPatternCodepoints.has(code)) { + $variantOffset = ((x * deviceCellWidth) % 2) * 2 + ((y * deviceCellHeight) % 2); + } // Apply decorations on the bottom layer this._decorationService.forEachDecorationAtCell(x, y, 'bottom', d => { if (d.backgroundColorRGB) { diff --git a/addons/addon-webgl/src/CharAtlasCache.ts b/addons/addon-webgl/src/CharAtlasCache.ts index 62ff4ff9..823a74cc 100644 --- a/addons/addon-webgl/src/CharAtlasCache.ts +++ b/addons/addon-webgl/src/CharAtlasCache.ts @@ -8,6 +8,7 @@ import { ITerminalOptions, Terminal } from '@xterm/xterm'; import { ITerminal, ReadonlyColorSet } from 'browser/Types'; import { ICharAtlasConfig, ITextureAtlas } from './Types'; import { generateConfig, configEquals } from './CharAtlasUtils'; +import type { ILogService } from 'common/services/Services'; interface ITextureAtlasCacheEntry { atlas: ITextureAtlas; @@ -67,8 +68,9 @@ export function acquireTextureAtlas( } const core: ITerminal = (terminal as any)._core; + const logService = (core as any)._logService as ILogService; const newEntry: ITextureAtlasCacheEntry = { - atlas: new TextureAtlas(document, newConfig, core.unicodeService), + atlas: new TextureAtlas(document, newConfig, core.unicodeService, logService), config: newConfig, ownedBy: [terminal] }; diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index aa0e2653..5629508f 100644 --- a/addons/addon-webgl/src/TextureAtlas.ts +++ b/addons/addon-webgl/src/TextureAtlas.ts @@ -14,7 +14,7 @@ import { IdleTaskQueue } from 'common/TaskQueue'; import { IColor } from 'common/Types'; import { AttributeData } from 'common/buffer/AttributeData'; import { Attributes, DEFAULT_COLOR, DEFAULT_EXT, UnderlineStyle } from 'common/buffer/Constants'; -import { IUnicodeService } from 'common/services/Services'; +import { ILogService, IUnicodeService } from 'common/services/Services'; import { Emitter } from 'common/Event'; /** @@ -88,7 +88,8 @@ export class TextureAtlas implements ITextureAtlas { constructor( private readonly _document: Document, private readonly _config: ICharAtlasConfig, - private readonly _unicodeService: IUnicodeService + private readonly _unicodeService: IUnicodeService, + private readonly _logService: ILogService ) { this._createNewPage(); this._tmpCanvas = createCanvas( @@ -119,7 +120,7 @@ export class TextureAtlas implements ITextureAtlas { private _doWarmUp(): void { // Pre-fill with ASCII 33-126, this is not urgent and done in idle callbacks - const queue = new IdleTaskQueue(); + const queue = new IdleTaskQueue(this._logService); for (let i = 33; i < 126; i++) { queue.enqueue(() => { if (!this._cacheMap.get(i, DEFAULT_COLOR, DEFAULT_COLOR, DEFAULT_EXT)) { @@ -525,7 +526,8 @@ export class TextureAtlas implements ITextureAtlas { // Draw custom characters if applicable let customGlyph = false; if (this._config.customGlyphs !== false) { - customGlyph = tryDrawCustomGlyph(this._tmpCtx, chars, padding, padding, this._config.deviceCellWidth, this._config.deviceCellHeight, this._config.deviceCharWidth, this._config.deviceCharHeight, this._config.fontSize, this._config.devicePixelRatio, backgroundColor.css); + const variantOffset = this._workAttributeData.getUnderlineVariantOffset(); + customGlyph = tryDrawCustomGlyph(this._tmpCtx, chars, padding, padding, this._config.deviceCellWidth, this._config.deviceCellHeight, this._config.deviceCharWidth, this._config.deviceCharHeight, this._config.fontSize, this._config.devicePixelRatio, backgroundColor.css, variantOffset); } // Whether to clear pixels based on a threshold difference between the glyph color and the diff --git a/addons/addon-webgl/src/WebglRenderer.ts b/addons/addon-webgl/src/WebglRenderer.ts index c2f873c8..e985bf2e 100644 --- a/addons/addon-webgl/src/WebglRenderer.ts +++ b/addons/addon-webgl/src/WebglRenderer.ts @@ -500,7 +500,7 @@ export class WebglRenderer extends Disposable implements IRenderer { } // Load colors/resolve overrides into work colors - this._cellColorResolver.resolve(cell, x, row, this.dimensions.device.cell.width); + this._cellColorResolver.resolve(cell, x, row, this.dimensions.device.cell.width, this.dimensions.device.cell.height); // Override colors for cursor cell if (isCursorVisible && row === cursorY) { diff --git a/addons/addon-webgl/src/customGlyphs/CustomGlyphDefinitions.ts b/addons/addon-webgl/src/customGlyphs/CustomGlyphDefinitions.ts index 79efc306..22ffdfa9 100644 --- a/addons/addon-webgl/src/customGlyphs/CustomGlyphDefinitions.ts +++ b/addons/addon-webgl/src/customGlyphs/CustomGlyphDefinitions.ts @@ -644,8 +644,8 @@ export const customGlyphDefinitions: { [index: string]: CustomGlyphCharacterDefi ] }, // Diagonal fill characters (1FB98-1FB99) - '\u{1FB98}': { type: CustomGlyphDefinitionType.PATH_FUNCTION, data: 'M0,0 L1,1 M0,.25 L.75,1 M0,.5 L.5,1 M0,.75 L.25,1 M.25,0 L1,.75 M.5,0 L1,.5 M.75,0 L1,.25', strokeWidth: 1 }, // UPPER LEFT TO LOWER RIGHT FILL - '\u{1FB99}': { type: CustomGlyphDefinitionType.PATH_FUNCTION, data: 'M0,.25 L.25,0 M0,.5 L.5,0 M0,.75 L.75,0 M0,1 L1,0 M.25,1 L1,.25 M.5,1 L1,.5 M.75,1 L1,.75', strokeWidth: 1 }, // UPPER RIGHT TO LOWER LEFT FILL + '\u{1FB98}': { type: CustomGlyphDefinitionType.PATH_FUNCTION, data: 'M-0.25,-0.25 L1.25,1.25 M-0.25,0 L1,1.25 M-0.25,0.25 L0.75,1.25 M-0.25,0.5 L0.5,1.25 M0,-0.25 L1.25,1 M0.25,-0.25 L1.25,0.75 M0.5,-0.25 L1.25,0.5 M-0.25,0.75 L0.25,1.25 M0.75,-0.25 L1.25,0.25', strokeWidth: 1 }, // UPPER LEFT TO LOWER RIGHT FILL + '\u{1FB99}': { type: CustomGlyphDefinitionType.PATH_FUNCTION, data: 'M-0.25,0.5 L0.5,-0.25 M-0.25,0.75 L0.75,-0.25 M-0.25,1 L1,-0.25 M-0.25,1.25 L1.25,-0.25 M0,1.25 L1.25,0 M0.25,1.25 L1.25,0.25 M0.5,1.25 L1.25,0.5 M-0.25,0.25 L0.25,-0.25 M0.75,1.25 L1.25,0.75', strokeWidth: 1 }, // UPPER RIGHT TO LOWER LEFT FILL // Smooth mosaic terminal graphic characters (1FB9A-1FB9B) '\u{1FB9A}': { type: CustomGlyphDefinitionType.VECTOR_SHAPE, data: { d: 'M0,0 L.5,.5 L0,1 L1,1 L.5,.5 L1,0', type: CustomGlyphVectorType.FILL } }, // UPPER AND LOWER TRIANGULAR HALF BLOCK @@ -820,6 +820,27 @@ export const customGlyphDefinitions: { [index: string]: CustomGlyphCharacterDefi // #endregion }; +export const blockPatternCodepoints = new Set([ + // Shade characters (2591-2593) + 0x2591, + 0x2592, + 0x2593, + // Rectangular shade characters (1FB8C-1FB94) + 0x1FB8C, + 0x1FB8D, + 0x1FB8E, + 0x1FB8F, + 0x1FB90, + 0x1FB91, + 0x1FB92, + 0x1FB94, + // Triangular shade characters (1FB9C-1FB9F) + 0x1FB9C, + 0x1FB9D, + 0x1FB9E, + 0x1FB9F +]); + /** * Generates a drawing function for sextant characters. Sextants are a 2x3 grid where each cell * can be on or off. diff --git a/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts b/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts index 6302757f..ba892572 100644 --- a/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts +++ b/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts @@ -22,14 +22,15 @@ export function tryDrawCustomGlyph( deviceCharHeight: number, fontSize: number, devicePixelRatio: number, - backgroundColor?: string + backgroundColor?: string, + variantOffset: number = 0 ): boolean { const unifiedCharDefinition = customGlyphDefinitions[c]; if (unifiedCharDefinition) { // Normalize to array for uniform handling const parts = Array.isArray(unifiedCharDefinition) ? unifiedCharDefinition : [unifiedCharDefinition]; for (const part of parts) { - drawDefinitionPart(ctx, part, xOffset, yOffset, deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, fontSize, devicePixelRatio, backgroundColor); + drawDefinitionPart(ctx, part, xOffset, yOffset, deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, fontSize, devicePixelRatio, backgroundColor, variantOffset); } return true; } @@ -48,7 +49,8 @@ function drawDefinitionPart( deviceCharHeight: number, fontSize: number, devicePixelRatio: number, - backgroundColor?: string + backgroundColor?: string, + variantOffset: number = 0 ): void { // Handle scaleType - adjust dimensions and offset when scaling to character area let drawWidth = deviceCellWidth; @@ -74,7 +76,7 @@ function drawDefinitionPart( drawBlockVectorChar(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight); break; case CustomGlyphDefinitionType.BLOCK_PATTERN: - drawPatternChar(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight); + drawPatternChar(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, variantOffset); break; case CustomGlyphDefinitionType.PATH_FUNCTION: drawPathFunctionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, part.strokeWidth); @@ -437,7 +439,8 @@ function drawPatternChar( xOffset: number, yOffset: number, deviceCellWidth: number, - deviceCellHeight: number + deviceCellHeight: number, + variantOffset: number = 0 ): void { let patternSet = cachedPatterns.get(charDefinition); if (!patternSet) { @@ -486,6 +489,15 @@ function drawPatternChar( pattern = throwIfFalsy(ctx.createPattern(tmpCanvas, null)); patternSet.set(fillStyle, pattern); } + // Apply pattern offset to ensure seamless tiling across cells when cell dimensions are odd. + // variantOffset encodes: bit 1 = x pixel shift, bit 0 = y pixel shift. + const dx = (variantOffset >> 1) & 1; + const dy = variantOffset & 1; + if (dx !== 0 || dy !== 0) { + pattern.setTransform(new DOMMatrix().translateSelf(-dx, -dy)); + } else { + pattern.setTransform(new DOMMatrix()); + } ctx.fillStyle = pattern; ctx.fillRect(xOffset, yOffset, deviceCellWidth, deviceCellHeight); } @@ -500,6 +512,11 @@ function drawPathFunctionCharacter( devicePixelRatio: number, strokeWidth?: number ): void { + ctx.save(); + ctx.beginPath(); + ctx.rect(xOffset, yOffset, deviceCellWidth, deviceCellHeight); + ctx.clip(); + ctx.beginPath(); let actualInstructions: string; if (typeof charDefinition === 'function') { @@ -526,7 +543,7 @@ function drawPathFunctionCharacter( if (!args[0] || !args[1]) { continue; } - f(ctx, translateArgs(args, deviceCellWidth, deviceCellHeight, xOffset, yOffset, true, devicePixelRatio), state); + f(ctx, translateArgs(args, deviceCellWidth, deviceCellHeight, xOffset, yOffset, true, devicePixelRatio, 0, 0, false), state); state.lastCommand = type; } if (strokeWidth !== undefined) { @@ -537,6 +554,7 @@ function drawPathFunctionCharacter( ctx.fill(); } ctx.closePath(); + ctx.restore(); } /** @@ -685,7 +703,7 @@ const svgToCanvasInstructionMap: { [index: string]: (ctx: CanvasRenderingContext } }; -function translateArgs(args: string[], cellWidth: number, cellHeight: number, xOffset: number, yOffset: number, doClamp: boolean, devicePixelRatio: number, leftPadding: number = 0, rightPadding: number = 0): number[] { +function translateArgs(args: string[], cellWidth: number, cellHeight: number, xOffset: number, yOffset: number, doClamp: boolean, devicePixelRatio: number, leftPadding: number = 0, rightPadding: number = 0, clampToCell: boolean = true): number[] { const result = args.map(e => parseFloat(e) || parseInt(e)); if (result.length < 2) { @@ -695,10 +713,11 @@ function translateArgs(args: string[], cellWidth: number, cellHeight: number, xO for (let x = 0; x < result.length; x += 2) { // Translate from 0-1 to 0-cellWidth result[x] *= cellWidth - (leftPadding * devicePixelRatio) - (rightPadding * devicePixelRatio); - // Ensure coordinate doesn't escape cell bounds and round to the nearest 0.5 to ensure a crisp - // line at 100% devicePixelRatio + // Round to the nearest 0.5 to ensure a crisp line at 100% devicePixelRatio, and optionally + // clamp to the cell bounds. if (doClamp && result[x] !== 0) { - result[x] = clamp(Math.round(result[x] + 0.5) - 0.5, cellWidth, 0); + const rounded = Math.round(result[x] + 0.5) - 0.5; + result[x] = clampToCell ? clamp(rounded, cellWidth, 0) : rounded; } // Apply the cell's offset (ie. x*cellWidth) result[x] += xOffset + (leftPadding * devicePixelRatio); @@ -707,10 +726,11 @@ function translateArgs(args: string[], cellWidth: number, cellHeight: number, xO for (let y = 1; y < result.length; y += 2) { // Translate from 0-1 to 0-cellHeight result[y] *= cellHeight; - // Ensure coordinate doesn't escape cell bounds and round to the nearest 0.5 to ensure a crisp - // line at 100% devicePixelRatio + // Round to the nearest 0.5 to ensure a crisp line at 100% devicePixelRatio, and optionally + // clamp to the cell bounds. if (doClamp && result[y] !== 0) { - result[y] = clamp(Math.round(result[y] + 0.5) - 0.5, cellHeight, 0); + const rounded = Math.round(result[y] + 0.5) - 0.5; + result[y] = clampToCell ? clamp(rounded, cellHeight, 0) : rounded; } // Apply the cell's offset (ie. x*cellHeight) result[y] += yOffset; diff --git a/demo/client/client.ts b/demo/client/client.ts index 24fd6e2f..77020680 100644 --- a/demo/client/client.ts +++ b/demo/client/client.ts @@ -284,7 +284,7 @@ function createTerminal(): Terminal { const isWindows = ['Windows', 'Win16', 'Win32', 'WinCE'].indexOf(navigator.platform) >= 0; term = new Terminal({ - scrollbar: { showArrows: true }, + scrollbar: { showScrollbar: true }, allowProposedApi: true, windowsPty: isWindows ? { // In a real scenario, these values should be verified on the backend diff --git a/demo/client/components/window/optionsWindow.ts b/demo/client/components/window/optionsWindow.ts index b97204d2..c24fb268 100644 --- a/demo/client/components/window/optionsWindow.ts +++ b/demo/client/components/window/optionsWindow.ts @@ -115,17 +115,20 @@ export class OptionsWindow extends BaseWindow implements IControlWindow { 'documentOverride', 'linkHandler', 'logger', - 'overviewRuler', 'quirks', 'theme', 'vtExtensions', 'windowOptions', 'windowsPty', ]; - const nestedBooleanOptions: { label: string, parent: string, prop: string }[] = [ - { label: 'vtExtensions.kittyKeyboard', parent: 'vtExtensions', prop: 'kittyKeyboard' }, - { label: 'vtExtensions.kittySgrBoldFaintControl', parent: 'vtExtensions', prop: 'kittySgrBoldFaintControl' }, - { label: 'vtExtensions.win32InputMode', parent: 'vtExtensions', prop: 'win32InputMode' } + const nestedBooleanOptions: { label: string, path: string[], prop: string }[] = [ + { label: 'scrollbar.showScrollbar', path: ['scrollbar'], prop: 'showScrollbar' }, + { label: 'scrollbar.showArrows', path: ['scrollbar'], prop: 'showArrows' }, + { label: 'scrollbar.overviewRuler.showTopBorder', path: ['scrollbar', 'overviewRuler'], prop: 'showTopBorder' }, + { label: 'scrollbar.overviewRuler.showBottomBorder', path: ['scrollbar', 'overviewRuler'], prop: 'showBottomBorder' }, + { label: 'vtExtensions.kittyKeyboard', path: ['vtExtensions'], prop: 'kittyKeyboard' }, + { label: 'vtExtensions.kittySgrBoldFaintControl', path: ['vtExtensions'], prop: 'kittySgrBoldFaintControl' }, + { label: 'vtExtensions.win32InputMode', path: ['vtExtensions'], prop: 'win32InputMode' } ]; const stringOptions: { [key: string]: string[] | null } = { cursorStyle: ['block', 'underline', 'bar'], @@ -161,8 +164,10 @@ export class OptionsWindow extends BaseWindow implements IControlWindow { booleanOptions.forEach(o => { html += `
`; }); - nestedBooleanOptions.forEach(({ label, parent, prop }) => { - const checked = (this._terminal.options as Record | undefined>)[parent]?.[prop] ?? false; + nestedBooleanOptions.forEach(({ label, path, prop }) => { + const options = this._terminal.options as Record; + const parent = path.reduce | undefined>((acc, key) => (acc as Record | undefined)?.[key] as Record | undefined, options); + const checked = (parent as Record | undefined)?.[prop] ?? false; html += `
`; }); html += '
'; @@ -196,11 +201,22 @@ export class OptionsWindow extends BaseWindow implements IControlWindow { } }); }); - nestedBooleanOptions.forEach(({ label, parent, prop }) => { + nestedBooleanOptions.forEach(({ label, path, prop }) => { const input = document.getElementById(`opt-${label.replace('.', '-')}`) as HTMLInputElement; addDomListener(input, 'change', () => { console.log('change', label, input.checked); - (this._terminal.options as Record)[parent] = { ...(this._terminal.options as Record | undefined>)[parent], [prop]: input.checked }; + const options = this._terminal.options as Record; + if (path.length === 1) { + const parentKey = path[0]; + options[parentKey] = { ...(options[parentKey] as Record | undefined), [prop]: input.checked }; + return; + } + if (path.length === 2) { + const [parentKey, childKey] = path; + const parent = (options[parentKey] as Record | undefined) ?? {}; + const child = (parent[childKey] as Record | undefined) ?? {}; + options[parentKey] = { ...parent, [childKey]: { ...child, [prop]: input.checked } }; + } }); }); numberOptions.forEach(o => { diff --git a/demo/client/components/window/testWindow.ts b/demo/client/components/window/testWindow.ts index 6f2c6c07..a67fef16 100644 --- a/demo/client/components/window/testWindow.ts +++ b/demo/client/components/window/testWindow.ts @@ -397,6 +397,13 @@ function customGlyphAlignmentHandler(term: Terminal): void { } } + term.write('\x1b[0mTriangular fill tests:\x1b[36m\n\r'); + term.write('1FB9C 1FB9D 1FB9E 1FB9F all\n\r'); + term.write('\u{02592}\u{02592}\u{02592}\u{1FB9C} \u{1FB9D}\u{02592}\u{02592}\u{02592} \u{00020}\u{00020}\u{00020}\u{1FB9E} \u{1FB9F}\u{00020}\u{00020}\u{00020} \u{00020}\u{1FB9E}\u{1FB9F}\u{00020}\n\r'); + term.write('\u{02592}\u{02592}\u{1FB9C}\u{00020} \u{00020}\u{1FB9D}\u{02592}\u{02592} \u{00020}\u{00020}\u{1FB9E}\u{02592} \u{02592}\u{1FB9F}\u{00020}\u{00020} \u{1FB9E}\u{02592}\u{02592}\u{1FB9F}\n\r'); + term.write('\u{02592}\u{1FB9C}\u{00020}\u{00020} \u{00020}\u{00020}\u{1FB9D}\u{02592} \u{00020}\u{1FB9E}\u{02592}\u{02592} \u{02592}\u{02592}\u{1FB9F}\u{00020} \u{1FB9D}\u{02592}\u{02592}\u{1FB9C}\n\r'); + term.write('\u{1FB9C}\u{00020}\u{00020}\u{00020} \u{00020}\u{00020}\u{00020}\u{1FB9D} \u{1FB9E}\u{02592}\u{02592}\u{02592} \u{02592}\u{02592}\u{02592}\u{1FB9F} \u{00020}\u{1FB9D}\u{1FB9C}\u{00020}\n\r'); + term.write('\x1b[0mPowerline alignment tests:\n\r'); const powerlineLeftChars = ['\u{E0B2}', '\u{E0B3}', '\u{E0B6}', '\u{E0B7}', '\u{E0BA}', '\u{E0BB}', '\u{E0BE}', '\u{E0BF}', '\u{E0C2}', '\u{E0C3}', '\u{E0C5}', '\u{E0C7}', '\u{E0CA}', '\u{E0D4}']; const powerlineRightChars = ['\u{E0B0}', '\u{E0B1}', '\u{E0B4}', '\u{E0B5}', '\u{E0B8}', '\u{E0B9}', '\u{E0BC}', '\u{E0BD}', '\u{E0C0}', '\u{E0C1}', '\u{E0C4}', '\u{E0C6}', '\u{E0C8}', '\u{E0D2}', '\u{E0CC}', '\u{E0CD}', '\u{E0CE}', '\u{E0CF}', '\u{E0D0}', '\u{E0D1}']; @@ -738,7 +745,7 @@ function loadTestLongLines(term: Terminal, addons: AddonCollection): void { } function addDecoration(term: Terminal, dim: number = 1): void { - term.options['overviewRuler'] = { width: 14 }; + term.options.scrollbar = { ...(term.options.scrollbar ?? {}), width: 14, overviewRuler: term.options.scrollbar?.overviewRuler ?? {} }; const marker = term.registerMarker(1); const decoration = term.registerDecoration({ marker, @@ -755,7 +762,7 @@ function addDecoration(term: Terminal, dim: number = 1): void { } function addOverviewRuler(term: Terminal): void { - term.options['overviewRuler'] = { width: 14 }; + term.options.scrollbar = { ...(term.options.scrollbar ?? {}), width: 14, overviewRuler: term.options.scrollbar?.overviewRuler ?? {} }; term.registerDecoration({ marker: term.registerMarker(1), overviewRulerOptions: { color: '#ef2929' } }); term.registerDecoration({ marker: term.registerMarker(3), overviewRulerOptions: { color: '#8ae234' } }); term.registerDecoration({ marker: term.registerMarker(5), overviewRulerOptions: { color: '#729fcf' } }); diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index b7958570..8ad8409f 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -616,11 +616,14 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { } this._register(this.optionsService.onSpecificOptionChange('screenReaderMode', e => this._handleScreenReaderModeOptionChange(e))); - if (this.options.overviewRuler.width) { + const showScrollbar = this.options.scrollbar?.showScrollbar ?? true; + const overviewRulerWidth = this.options.scrollbar?.width; + if (showScrollbar && overviewRulerWidth) { this._overviewRulerRenderer = this._register(this._instantiationService.createInstance(OverviewRulerRenderer, this._viewportElement, this.screenElement)); } - this.optionsService.onSpecificOptionChange('overviewRuler', value => { - if (!this._overviewRulerRenderer && value && this._viewportElement && this.screenElement) { + this.optionsService.onSpecificOptionChange('scrollbar', value => { + const shouldShow = (value?.showScrollbar ?? true) && !!value?.width; + if (!this._overviewRulerRenderer && shouldShow && this._viewportElement && this.screenElement) { this._overviewRulerRenderer = this._register(this._instantiationService.createInstance(OverviewRulerRenderer, this._viewportElement, this.screenElement)); } }); diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts index f036f619..09864f7a 100644 --- a/src/browser/Viewport.ts +++ b/src/browser/Viewport.ts @@ -62,7 +62,7 @@ export class Viewport extends Disposable { this._register(this._optionsService.onMultipleOptionChange([ 'scrollSensitivity', 'fastScrollSensitivity', - 'overviewRuler' + 'scrollbar' ], () => this._scrollableElement.updateOptions(this._getChangeOptions()))); // Don't handle mouse wheel if wheel events are supported by the current mouse prototcol this._register(coreMouseService.onProtocolChange(type => { @@ -131,10 +131,17 @@ export class Viewport extends Disposable { } private _getChangeOptions(): IScrollableElementChangeOptions { + const showScrollbar = this._optionsService.rawOptions.scrollbar?.showScrollbar ?? true; + const showArrows = this._optionsService.rawOptions.scrollbar?.showArrows ?? false; + const verticalScrollbarSize = showScrollbar + ? (this._optionsService.rawOptions.scrollbar?.width ?? ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH) + : 0; return { mouseWheelScrollSensitivity: this._optionsService.rawOptions.scrollSensitivity, fastScrollSensitivity: this._optionsService.rawOptions.fastScrollSensitivity, - verticalScrollbarSize: this._optionsService.rawOptions.overviewRuler?.width || ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH + vertical: showScrollbar ? ScrollbarVisibility.AUTO : ScrollbarVisibility.HIDDEN, + verticalScrollbarSize, + verticalHasArrows: showArrows }; } diff --git a/src/browser/decorations/OverviewRulerRenderer.ts b/src/browser/decorations/OverviewRulerRenderer.ts index ea36cf76..4302b342 100644 --- a/src/browser/decorations/OverviewRulerRenderer.ts +++ b/src/browser/decorations/OverviewRulerRenderer.ts @@ -38,7 +38,12 @@ export class OverviewRulerRenderer extends Disposable { private readonly _ctx: CanvasRenderingContext2D; private readonly _colorZoneStore: IColorZoneStore = new ColorZoneStore(); private get _width(): number { - return this._optionsService.options.overviewRuler?.width || 0; + const scrollbar = this._optionsService.rawOptions.scrollbar; + const showScrollbar = scrollbar?.showScrollbar ?? true; + if (!showScrollbar) { + return 0; + } + return scrollbar?.width ?? 0; } private _animationFrame: number | undefined; @@ -46,8 +51,6 @@ export class OverviewRulerRenderer extends Disposable { private _shouldUpdateAnchor: boolean | undefined = true; private _lastKnownBufferLength: number = 0; - private _containerHeight: number | undefined; - constructor( private readonly _viewportElement: HTMLElement, private readonly _screenElement: HTMLElement, @@ -86,16 +89,10 @@ export class OverviewRulerRenderer extends Disposable { } })); - // Container height changed - this._register(this._renderService.onRender((): void => { - if (!this._containerHeight || this._containerHeight !== this._screenElement.clientHeight) { - this._queueRefresh(true); - this._containerHeight = this._screenElement.clientHeight; - } - })); + this._register(this._renderService.onDimensionsChange(() => this._queueRefresh(true))); this._register(this._coreBrowserService.onDprChange(() => this._queueRefresh(true))); - this._register(this._optionsService.onSpecificOptionChange('overviewRuler', () => this._queueRefresh(true))); + this._register(this._optionsService.onSpecificOptionChange('scrollbar', () => this._queueRefresh(true))); this._register(this._themeService.onChangeColors(() => this._queueRefresh())); this._queueRefresh(true); } @@ -139,10 +136,12 @@ export class OverviewRulerRenderer extends Disposable { } private _refreshCanvasDimensions(): void { + const cssCanvasHeight = this._renderService.dimensions.css.canvas.height; + const deviceCanvasHeight = this._renderService.dimensions.device.canvas.height; this._canvas.style.width = `${this._width}px`; this._canvas.width = Math.round(this._width * this._coreBrowserService.dpr); - this._canvas.style.height = `${this._screenElement.clientHeight}px`; - this._canvas.height = Math.round(this._screenElement.clientHeight * this._coreBrowserService.dpr); + this._canvas.style.height = `${cssCanvasHeight}px`; + this._canvas.height = deviceCanvasHeight; this._refreshDrawConstants(); this._refreshColorZonePadding(); } @@ -176,10 +175,10 @@ export class OverviewRulerRenderer extends Disposable { private _renderRulerOutline(): void { this._ctx.fillStyle = this._themeService.colors.overviewRulerBorder.css; this._ctx.fillRect(0, 0, Constants.OVERVIEW_RULER_BORDER_WIDTH, this._canvas.height); - if (this._optionsService.rawOptions.overviewRuler.showTopBorder) { + if (this._optionsService.rawOptions.scrollbar?.overviewRuler?.showTopBorder) { this._ctx.fillRect(Constants.OVERVIEW_RULER_BORDER_WIDTH, 0, this._canvas.width - Constants.OVERVIEW_RULER_BORDER_WIDTH, Constants.OVERVIEW_RULER_BORDER_WIDTH); } - if (this._optionsService.rawOptions.overviewRuler.showBottomBorder) { + if (this._optionsService.rawOptions.scrollbar?.overviewRuler?.showBottomBorder) { this._ctx.fillRect(Constants.OVERVIEW_RULER_BORDER_WIDTH, this._canvas.height - Constants.OVERVIEW_RULER_BORDER_WIDTH, this._canvas.width - Constants.OVERVIEW_RULER_BORDER_WIDTH, this._canvas.height); } } diff --git a/src/browser/public/Terminal.ts b/src/browser/public/Terminal.ts index 4a77fd4a..f25da387 100644 --- a/src/browser/public/Terminal.ts +++ b/src/browser/public/Terminal.ts @@ -83,6 +83,7 @@ export class Terminal extends Disposable implements ITerminalApi { public get onDimensionsChange(): IEvent { return this._core.onDimensionsChange; } public get element(): HTMLElement | undefined { return this._core.element; } + public get screenElement(): HTMLElement | undefined { return this._core.screenElement; } public get parser(): IParser { return this._parser ??= new ParserApi(this._core); } diff --git a/src/browser/renderer/dom/DomRendererRowFactory.ts b/src/browser/renderer/dom/DomRendererRowFactory.ts index 1f276c78..0cff9c25 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.ts @@ -124,7 +124,7 @@ export class DomRendererRowFactory { // 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. - let cell = this._workCell; + let cell: ICellData = this._workCell; if (joinedRanges.length > 0 && x === joinedRanges[0][0] && isValidJoinRange) { const range = joinedRanges.shift()!; // If the ligature's selection state is not consistent, don't join it. This helps the diff --git a/src/browser/scrollable/abstractScrollbar.ts b/src/browser/scrollable/abstractScrollbar.ts index 4870f665..64c25d9d 100644 --- a/src/browser/scrollable/abstractScrollbar.ts +++ b/src/browser/scrollable/abstractScrollbar.ts @@ -82,10 +82,11 @@ export abstract class AbstractScrollbar extends Widget { /** * Creates the dom node for an arrow & adds it to the container */ - protected _createArrow(opts: IScrollbarArrowOptions): void { + protected _createArrow(opts: IScrollbarArrowOptions): ScrollbarArrow { const arrow = this._register(new ScrollbarArrow(opts)); this.domNode.domNode.appendChild(arrow.bgDomNode); this.domNode.domNode.appendChild(arrow.domNode); + return arrow; } /** diff --git a/src/browser/scrollable/scrollableElement.ts b/src/browser/scrollable/scrollableElement.ts index 7014c845..e30b36fc 100644 --- a/src/browser/scrollable/scrollableElement.ts +++ b/src/browser/scrollable/scrollableElement.ts @@ -314,6 +314,12 @@ export class SmoothScrollableElement extends Widget { if (typeof newOptions.vertical !== 'undefined') { this._options.vertical = newOptions.vertical; } + if (typeof newOptions.horizontalHasArrows !== 'undefined') { + this._options.horizontalHasArrows = newOptions.horizontalHasArrows; + } + if (typeof newOptions.verticalHasArrows !== 'undefined') { + this._options.verticalHasArrows = newOptions.verticalHasArrows; + } if (typeof newOptions.horizontalScrollbarSize !== 'undefined') { this._options.horizontalScrollbarSize = newOptions.horizontalScrollbarSize; } diff --git a/src/browser/scrollable/scrollableElementOptions.ts b/src/browser/scrollable/scrollableElementOptions.ts index 8b241a60..037cd5c1 100644 --- a/src/browser/scrollable/scrollableElementOptions.ts +++ b/src/browser/scrollable/scrollableElementOptions.ts @@ -128,8 +128,10 @@ export interface IScrollableElementChangeOptions { scrollPredominantAxis?: boolean; horizontal?: ScrollbarVisibility; horizontalScrollbarSize?: number; + horizontalHasArrows?: boolean; vertical?: ScrollbarVisibility; verticalScrollbarSize?: number; + verticalHasArrows?: boolean; scrollByPage?: boolean; } diff --git a/src/browser/scrollable/scrollbarState.ts b/src/browser/scrollable/scrollbarState.ts index 9af66401..f9129f1b 100644 --- a/src/browser/scrollable/scrollbarState.ts +++ b/src/browser/scrollable/scrollbarState.ts @@ -35,7 +35,7 @@ export class ScrollbarState { * For the vertical scrollbar: the height of the scrollbar's arrows. * For the horizontal scrollbar: the width of the scrollbar's arrows. */ - private readonly _arrowSize: number; + private _arrowSize: number; // --- variables /** @@ -127,6 +127,14 @@ export class ScrollbarState { this._scrollbarSize = Math.round(scrollbarSize); } + public setArrowSize(arrowSize: number): void { + const iArrowSize = Math.round(arrowSize); + if (this._arrowSize !== iArrowSize) { + this._arrowSize = iArrowSize; + this._refreshComputedValues(); + } + } + public setOppositeScrollbarSize(oppositeScrollbarSize: number): void { this._oppositeScrollbarSize = Math.round(oppositeScrollbarSize); } diff --git a/src/browser/scrollable/verticalScrollbar.ts b/src/browser/scrollable/verticalScrollbar.ts index 9efbe4d8..615f443a 100644 --- a/src/browser/scrollable/verticalScrollbar.ts +++ b/src/browser/scrollable/verticalScrollbar.ts @@ -7,17 +7,22 @@ import { AbstractScrollbar, ISimplifiedPointerEvent, IScrollbarHost } from './ab import { IScrollableElementResolvedOptions } from './scrollableElementOptions'; import { ScrollbarState } from './scrollbarState'; import { INewScrollPosition, Scrollable, ScrollbarVisibility, IScrollEvent } from './scrollable'; +import type { ScrollbarArrow } from './scrollbarArrow'; export class VerticalScrollbar extends AbstractScrollbar { + private _arrowUp: ScrollbarArrow | undefined; + private _arrowDown: ScrollbarArrow | undefined; + private _arrowScrollDelta: number = 0; constructor(scrollable: Scrollable, options: IScrollableElementResolvedOptions, host: IScrollbarHost) { const scrollDimensions = scrollable.getScrollDimensions(); const scrollPosition = scrollable.getCurrentScrollPosition(); + const hasArrows = options.verticalHasArrows; super({ lazyRender: options.lazyRender, host: host, scrollbarState: new ScrollbarState( - (options.verticalHasArrows ? options.verticalScrollbarSize : 0), + (hasArrows ? options.verticalScrollbarSize : 0), (options.vertical === ScrollbarVisibility.HIDDEN ? 0 : options.verticalScrollbarSize), 0, scrollDimensions.height, @@ -30,26 +35,7 @@ export class VerticalScrollbar extends AbstractScrollbar { scrollByPage: options.scrollByPage }); - if (options.verticalHasArrows) { - const arrowSize = options.verticalScrollbarSize; - const arrowDelta = 0; - this._createArrow({ - className: 'xterm-scra xterm-arrow-up', - top: arrowDelta, - left: arrowDelta, - bgWidth: options.verticalScrollbarSize, - bgHeight: arrowSize, - handleActivate: () => this._arrowScroll(-arrowSize) - }); - this._createArrow({ - className: 'xterm-scra xterm-arrow-down', - bottom: arrowDelta, - left: arrowDelta, - bgWidth: options.verticalScrollbarSize, - bgHeight: arrowSize, - handleActivate: () => this._arrowScroll(arrowSize) - }); - } + this._setArrows(hasArrows, options.verticalScrollbarSize); this._createSlider(0, Math.floor((options.verticalScrollbarSize - options.verticalSliderSize) / 2), options.verticalSliderSize, undefined); } @@ -98,7 +84,56 @@ export class VerticalScrollbar extends AbstractScrollbar { this._scrollable.setScrollPositionNow({ scrollTop: currentPosition.scrollTop + delta }); } + private _setArrows(showArrows: boolean, size: number): void { + this._arrowScrollDelta = size; + if (!this._arrowUp || !this._arrowDown) { + const arrowDelta = 0; + this._arrowUp = this._createArrow({ + className: 'xterm-scra xterm-arrow-up', + top: arrowDelta, + left: arrowDelta, + bgWidth: size, + bgHeight: size, + handleActivate: () => this._arrowScroll(-this._arrowScrollDelta) + }); + this._arrowDown = this._createArrow({ + className: 'xterm-scra xterm-arrow-down', + bottom: arrowDelta, + left: arrowDelta, + bgWidth: size, + bgHeight: size, + handleActivate: () => this._arrowScroll(this._arrowScrollDelta) + }); + } + + this._updateArrowSize(this._arrowUp, size); + this._updateArrowSize(this._arrowDown, size); + + if (!this._arrowUp || !this._arrowDown) { + return; + } + + const display = showArrows ? '' : 'none'; + this._arrowUp.bgDomNode.style.display = display; + this._arrowUp.domNode.style.display = display; + this._arrowDown.bgDomNode.style.display = display; + this._arrowDown.domNode.style.display = display; + } + + private _updateArrowSize(arrow: ScrollbarArrow | undefined, size: number): void { + if (!arrow) { + return; + } + arrow.bgDomNode.style.width = `${size}px`; + arrow.bgDomNode.style.height = `${size}px`; + arrow.domNode.style.width = `${size}px`; + arrow.domNode.style.height = `${size}px`; + } + public updateOptions(options: IScrollableElementResolvedOptions): void { + const arrowSize = options.verticalHasArrows ? options.verticalScrollbarSize : 0; + this._scrollbarState.setArrowSize(arrowSize); + this._setArrows(options.verticalHasArrows, options.verticalScrollbarSize); this.updateScrollbarSize(options.vertical === ScrollbarVisibility.HIDDEN ? 0 : options.verticalScrollbarSize); this._scrollbarState.setOppositeScrollbarSize(0); this._visibilityController.setVisibility(options.vertical); diff --git a/src/browser/services/RenderService.ts b/src/browser/services/RenderService.ts index 63f83c5b..ada3df30 100644 --- a/src/browser/services/RenderService.ts +++ b/src/browser/services/RenderService.ts @@ -9,7 +9,7 @@ import { IRenderDimensions, IRenderer } from 'browser/renderer/shared/Types'; import { ICharSizeService, ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services'; import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle'; import { DebouncedIdleTask } from 'common/TaskQueue'; -import { IBufferService, ICoreService, IDecorationService, IOptionsService } from 'common/services/Services'; +import { IBufferService, ICoreService, IDecorationService, ILogService, IOptionsService } from 'common/services/Services'; import { Emitter } from 'common/Event'; interface ISelectionState { @@ -27,7 +27,7 @@ export class RenderService extends Disposable implements IRenderService { private _renderer: MutableDisposable = this._register(new MutableDisposable()); private _renderDebouncer: IRenderDebouncerWithCallback; - private _pausedResizeTask = new DebouncedIdleTask(); + private _pausedResizeTask: DebouncedIdleTask; private _observerDisposable = this._register(new MutableDisposable()); private _isPaused: boolean = false; @@ -58,6 +58,7 @@ export class RenderService extends Disposable implements IRenderService { private _rowCount: number, screenElement: HTMLElement, @IOptionsService private readonly _optionsService: IOptionsService, + @ILogService private readonly _logService: ILogService, @ICharSizeService private readonly _charSizeService: ICharSizeService, @ICoreService private readonly _coreService: ICoreService, @IDecorationService decorationService: IDecorationService, @@ -67,6 +68,8 @@ export class RenderService extends Disposable implements IRenderService { ) { super(); + this._pausedResizeTask = new DebouncedIdleTask(this._logService); + this._renderDebouncer = new RenderDebouncer((start, end) => this._renderRows(start, end), this._coreBrowserService); this._register(this._renderDebouncer); diff --git a/src/browser/services/SelectionService.ts b/src/browser/services/SelectionService.ts index 95fa88cd..a5d1d759 100644 --- a/src/browser/services/SelectionService.ts +++ b/src/browser/services/SelectionService.ts @@ -11,7 +11,7 @@ import { ISelectionRedrawRequestEvent, ISelectionRequestScrollLinesEvent } from import { ICoreBrowserService, IMouseService, IRenderService, ISelectionService } from 'browser/services/Services'; import { Disposable, toDisposable } from 'common/Lifecycle'; import * as Browser from 'common/Platform'; -import { IBufferLine, IDisposable } from 'common/Types'; +import { IBufferLine, ICellData, IDisposable } from 'common/Types'; import { getRangeLength } from 'common/buffer/BufferRange'; import { CellData } from 'common/buffer/CellData'; import { IBuffer } from 'common/buffer/Types'; @@ -1021,7 +1021,7 @@ export class SelectionService extends Disposable implements ISelectionService { * word logic. * @param cell The cell to check. */ - private _isCharWordSeparator(cell: CellData): boolean { + private _isCharWordSeparator(cell: ICellData): boolean { // Zero width characters are never separators as they are always to the // right of wide characters if (cell.getWidth() === 0) { diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index 99d1411c..0ce19a4d 100644 --- a/src/common/CoreTerminal.ts +++ b/src/common/CoreTerminal.ts @@ -107,10 +107,10 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { this._instantiationService = new InstantiationService(); this.optionsService = this._register(new OptionsService(options)); this._instantiationService.setService(IOptionsService, this.optionsService); - this._bufferService = this._register(this._instantiationService.createInstance(BufferService)); - this._instantiationService.setService(IBufferService, this._bufferService); this._logService = this._register(this._instantiationService.createInstance(LogService)); this._instantiationService.setService(ILogService, this._logService); + this._bufferService = this._register(this._instantiationService.createInstance(BufferService)); + this._instantiationService.setService(IBufferService, this._bufferService); this.coreService = this._register(this._instantiationService.createInstance(CoreService)); this._instantiationService.setService(ICoreService, this.coreService); this.coreMouseService = this._register(this._instantiationService.createInstance(CoreMouseService)); diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts index d7026e50..f36c28b4 100644 --- a/src/common/InputHandler.test.ts +++ b/src/common/InputHandler.test.ts @@ -65,7 +65,7 @@ describe('InputHandler', () => { beforeEach(() => { optionsService = new MockOptionsService(); - bufferService = new BufferService(optionsService); + bufferService = new BufferService(optionsService, new MockLogService()); bufferService.resize(80, 30); coreService = new CoreService(bufferService, new MockLogService(), optionsService); oscLinkService = new OscLinkService(bufferService); @@ -2458,7 +2458,7 @@ describe('InputHandler', () => { beforeEach(() => { optionsService = new MockOptionsService({ vtExtensions: { kittyKeyboard: true } }); - bufferService = new BufferService(optionsService); + bufferService = new BufferService(optionsService, new MockLogService()); bufferService.resize(80, 30); coreService = new CoreService(bufferService, new MockLogService(), optionsService); inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService()); @@ -2508,7 +2508,7 @@ describe('InputHandler', () => { beforeEach(() => { optionsService = new MockOptionsService(); - bufferService = new BufferService(optionsService); + bufferService = new BufferService(optionsService, new MockLogService()); bufferService.resize(80, 30); coreService = new CoreService(bufferService, new MockLogService(), optionsService); coreService.onData(data => { console.log(data); }); diff --git a/src/common/SortedList.test.ts b/src/common/SortedList.test.ts index d2e01ba8..4718cf97 100644 --- a/src/common/SortedList.test.ts +++ b/src/common/SortedList.test.ts @@ -5,6 +5,7 @@ import { assert } from 'chai'; import { SortedList } from 'common/SortedList'; +import { MockLogService } from 'common/TestUtils.test'; const deepStrictEqual = assert.deepStrictEqual; @@ -15,7 +16,7 @@ describe('SortedList', () => { } beforeEach(() => { - list = new SortedList(e => e); + list = new SortedList(e => e, new MockLogService()); }); describe('insert', () => { @@ -90,7 +91,7 @@ describe('SortedList', () => { assertList([]); }); it('custom key', () => { - const customList = new SortedList<{ key: number }>(e => e.key); + const customList = new SortedList<{ key: number }>(e => e.key, new MockLogService()); customList.insert({ key: 5 }); customList.insert({ key: 2 }); customList.insert({ key: 10 }); diff --git a/src/common/SortedList.ts b/src/common/SortedList.ts index 82b6dfa6..c6dc6208 100644 --- a/src/common/SortedList.ts +++ b/src/common/SortedList.ts @@ -4,6 +4,7 @@ */ import { IdleTaskQueue } from 'common/TaskQueue'; +import type { ILogService } from 'common/services/Services'; // Work variables to avoid garbage collection. let i = 0; @@ -18,16 +19,19 @@ export class SortedList { private _array: T[] = []; private readonly _insertedValues: T[] = []; - private readonly _flushInsertedTask = new IdleTaskQueue(); + private readonly _flushInsertedTask: InstanceType; private _isFlushingInserted = false; private readonly _deletedIndices: number[] = []; - private readonly _flushDeletedTask = new IdleTaskQueue(); + private readonly _flushDeletedTask: InstanceType; private _isFlushingDeleted = false; constructor( - private readonly _getKey: (value: T) => number + private readonly _getKey: (value: T) => number, + logService: ILogService ) { + this._flushInsertedTask = new IdleTaskQueue(logService); + this._flushDeletedTask = new IdleTaskQueue(logService); } public clear(): void { diff --git a/src/common/TaskQueue.ts b/src/common/TaskQueue.ts index 40cddffd..508e5d46 100644 --- a/src/common/TaskQueue.ts +++ b/src/common/TaskQueue.ts @@ -4,6 +4,7 @@ */ import { isNode } from 'common/Platform'; +import type { ILogService } from 'common/services/Services'; interface ITaskQueue { /** @@ -34,6 +35,11 @@ abstract class TaskQueue implements ITaskQueue { private _tasks: (() => boolean | void)[] = []; private _idleCallback?: number; private _i = 0; + protected readonly _logService: ILogService; + + constructor(logService: ILogService) { + this._logService = logService; + } protected abstract _requestCallback(callback: CallbackWithDeadline): number; protected abstract _cancelCallback(identifier: number): void; @@ -90,7 +96,7 @@ abstract class TaskQueue implements ITaskQueue { // Warn when the time exceeding the deadline is over 20ms, if this happens in practice the // task should be split into sub-tasks to ensure the UI remains responsive. if (lastDeadlineRemaining - taskDuration < -20) { - console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(lastDeadlineRemaining - taskDuration))}ms`); + this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(lastDeadlineRemaining - taskDuration))}ms`); } this._start(); return; @@ -151,8 +157,8 @@ export const IdleTaskQueue = (!isNode && 'requestIdleCallback' in window) ? Idle export class DebouncedIdleTask { private _queue: ITaskQueue; - constructor() { - this._queue = new IdleTaskQueue(); + constructor(logService: ILogService) { + this._queue = new IdleTaskQueue(logService); } public set(task: () => boolean | void): void { diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index 908cae0c..6e6f0b64 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -27,7 +27,7 @@ export class MockBufferService implements IBufferService { public rows: number, optionsService: IOptionsService = new MockOptionsService() ) { - this.buffers = new BufferSet(optionsService, this); + this.buffers = new BufferSet(optionsService, this, new MockLogService()); // Listen to buffer activation events and automatically fire scroll events this.buffers.onBufferActivate(e => { this._onScroll.fire(e.activeBuffer.ydisp); diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts index 39cffb49..f9e3e1b1 100644 --- a/src/common/buffer/Buffer.test.ts +++ b/src/common/buffer/Buffer.test.ts @@ -6,7 +6,7 @@ import { assert } from 'chai'; import { Buffer } from 'common/buffer/Buffer'; import { CircularList } from 'common/CircularList'; -import { MockOptionsService, MockBufferService } from 'common/TestUtils.test'; +import { MockOptionsService, MockBufferService, MockLogService } from 'common/TestUtils.test'; import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { CellData } from 'common/buffer/CellData'; import { ExtendedAttrs } from 'common/buffer/AttributeData'; @@ -23,7 +23,7 @@ describe('Buffer', () => { beforeEach(() => { optionsService = new MockOptionsService({ scrollback: INIT_SCROLLBACK }); bufferService = new MockBufferService(INIT_COLS, INIT_ROWS); - buffer = new Buffer(true, optionsService, bufferService); + buffer = new Buffer(true, optionsService, bufferService, new MockLogService()); }); describe('constructor', () => { @@ -151,7 +151,7 @@ describe('Buffer', () => { describe('no scrollback', () => { it('should trim from the top of the buffer when the cursor reaches the bottom', () => { - buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService); + buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService()); assert.equal(buffer.lines.maxLength, INIT_ROWS); buffer.y = INIT_ROWS - 1; buffer.fillViewportRows(); @@ -1054,7 +1054,7 @@ describe('Buffer', () => { describe('buffer marked to have no scrollback', () => { it('should always have a scrollback of 0', () => { // Test size on initialization - buffer = new Buffer(false, new MockOptionsService({ scrollback: 1000 }), bufferService); + buffer = new Buffer(false, new MockOptionsService({ scrollback: 1000 }), bufferService, new MockLogService()); buffer.fillViewportRows(); assert.equal(buffer.lines.maxLength, INIT_ROWS); // Test size on buffer increase @@ -1068,7 +1068,7 @@ describe('Buffer', () => { describe('addMarker', () => { it('should adjust a marker line when the buffer is trimmed', () => { - buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService); + buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService()); buffer.fillViewportRows(); const marker = buffer.addMarker(buffer.lines.length - 1); assert.equal(marker.line, buffer.lines.length - 1); @@ -1076,7 +1076,7 @@ describe('Buffer', () => { assert.equal(marker.line, buffer.lines.length - 2); }); it('should dispose of a marker if it is trimmed off the buffer', () => { - buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService); + buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService()); buffer.fillViewportRows(); assert.equal(buffer.markers.length, 0); const marker = buffer.addMarker(0); @@ -1088,7 +1088,7 @@ describe('Buffer', () => { }); it('should call onDispose', () => { const eventStack: string[] = []; - buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService); + buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService()); buffer.fillViewportRows(); assert.equal(buffer.markers.length, 0); const marker = buffer.addMarker(0); diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index b738ffef..8efefd60 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -14,7 +14,7 @@ import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, import { Marker } from 'common/buffer/Marker'; import { IBuffer } from 'common/buffer/Types'; import { DEFAULT_CHARSET } from 'common/data/Charsets'; -import { IBufferService, IOptionsService } from 'common/services/Services'; +import { IBufferService, ILogService, IOptionsService } from 'common/services/Services'; export const MAX_BUFFER_SIZE = 4294967295; // 2^32 - 1 @@ -48,11 +48,14 @@ export class Buffer implements IBuffer { private _cols: number; private _rows: number; private _isClearing: boolean = false; + private _memoryCleanupQueue: InstanceType; + private _memoryCleanupPosition = 0; constructor( private _hasScrollback: boolean, private _optionsService: IOptionsService, - private _bufferService: IBufferService + private _bufferService: IBufferService, + private readonly _logService: ILogService ) { this._cols = this._bufferService.cols; this._rows = this._bufferService.rows; @@ -60,6 +63,7 @@ export class Buffer implements IBuffer { this.scrollTop = 0; this.scrollBottom = this._rows - 1; this.setupTabStops(); + this._memoryCleanupQueue = new IdleTaskQueue(this._logService); } public getNullCell(attr?: IAttributeData): ICellData { @@ -277,9 +281,6 @@ export class Buffer implements IBuffer { } } - private _memoryCleanupQueue = new IdleTaskQueue(); - private _memoryCleanupPosition = 0; - private _batchedMemoryCleanup(): boolean { let normalRun = true; if (this._memoryCleanupPosition >= this.lines.length) { diff --git a/src/common/buffer/BufferSet.test.ts b/src/common/buffer/BufferSet.test.ts index 944b8c15..96737fee 100644 --- a/src/common/buffer/BufferSet.test.ts +++ b/src/common/buffer/BufferSet.test.ts @@ -6,7 +6,7 @@ import { assert } from 'chai'; import { BufferSet } from 'common/buffer/BufferSet'; import { Buffer } from 'common/buffer/Buffer'; -import { MockOptionsService, MockBufferService } from 'common/TestUtils.test'; +import { MockOptionsService, MockBufferService, MockLogService } from 'common/TestUtils.test'; describe('BufferSet', () => { let bufferSet: BufferSet; @@ -14,7 +14,8 @@ describe('BufferSet', () => { beforeEach(() => { bufferSet = new BufferSet( new MockOptionsService({ scrollback: 1000 }), - new MockBufferService(80, 24) + new MockBufferService(80, 24), + new MockLogService() ); }); diff --git a/src/common/buffer/BufferSet.ts b/src/common/buffer/BufferSet.ts index d83d3925..772a7644 100644 --- a/src/common/buffer/BufferSet.ts +++ b/src/common/buffer/BufferSet.ts @@ -7,7 +7,7 @@ import { Disposable } from 'common/Lifecycle'; import { IAttributeData } from 'common/Types'; import { Buffer } from 'common/buffer/Buffer'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; -import { IBufferService, IOptionsService } from 'common/services/Services'; +import { IBufferService, ILogService, IOptionsService } from 'common/services/Services'; import { Emitter } from 'common/Event'; /** @@ -27,7 +27,8 @@ export class BufferSet extends Disposable implements IBufferSet { */ constructor( private readonly _optionsService: IOptionsService, - private readonly _bufferService: IBufferService + private readonly _bufferService: IBufferService, + private readonly _logService: ILogService ) { super(); this.reset(); @@ -36,12 +37,12 @@ export class BufferSet extends Disposable implements IBufferSet { } public reset(): void { - this._normal = new Buffer(true, this._optionsService, this._bufferService); + this._normal = new Buffer(true, this._optionsService, this._bufferService, this._logService); this._normal.fillViewportRows(); // The alt buffer should never have scrollback. // See http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-The-Alternate-Screen-Buffer - this._alt = new Buffer(false, this._optionsService, this._bufferService); + this._alt = new Buffer(false, this._optionsService, this._bufferService, this._logService); this._activeBuffer = this._normal; this._onBufferActivate.fire({ activeBuffer: this._normal, diff --git a/src/common/buffer/CellData.test.ts b/src/common/buffer/CellData.test.ts new file mode 100644 index 00000000..77d1ef9e --- /dev/null +++ b/src/common/buffer/CellData.test.ts @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ +import { Attributes, BgFlags, FgFlags, UnderlineStyle } from 'common/buffer/Constants'; +import { CellData } from 'common/buffer/CellData'; +import { assert } from 'chai'; + +function createStyledCell(char: string, underlineStyle: UnderlineStyle, underlineColor: number): CellData { + const cell = new CellData(); + const fg = Attributes.CM_P256 | 12 | FgFlags.BOLD | FgFlags.UNDERLINE; + cell.setFromCharData([fg, char, 1, char.charCodeAt(0)]); + cell.bg = Attributes.CM_P16 | 2 | BgFlags.ITALIC; + cell.extended.underlineStyle = underlineStyle; + cell.extended.underlineColor = Attributes.CM_P256 | underlineColor; + cell.updateExtended(); + return cell; +} + +describe('CellData', () => { + describe('attributesEquals', () => { + it('returns true for same attributes with different chars', () => { + const cellA = createStyledCell('A', UnderlineStyle.DOUBLE, 45); + const cellB = createStyledCell('B', UnderlineStyle.DOUBLE, 45); + + assert.equal(cellA.attributesEquals(cellB), true); + }); + + it('detects underline style changes', () => { + const cellA = createStyledCell('A', UnderlineStyle.DOUBLE, 45); + const cellB = createStyledCell('B', UnderlineStyle.SINGLE, 45); + + assert.equal(cellA.attributesEquals(cellB), false); + }); + + it('detects underline color changes', () => { + const cellA = createStyledCell('A', UnderlineStyle.SINGLE, 45); + const cellB = createStyledCell('B', UnderlineStyle.SINGLE, 46); + + assert.equal(cellA.attributesEquals(cellB), false); + }); + + it('ignores underline variant offsets', () => { + const cellA = createStyledCell('A', UnderlineStyle.SINGLE, 45); + const cellB = createStyledCell('B', UnderlineStyle.SINGLE, 45); + cellA.extended.underlineVariantOffset = 1; + cellB.extended.underlineVariantOffset = 3; + cellA.updateExtended(); + cellB.updateExtended(); + + assert.equal(cellA.attributesEquals(cellB), true); + }); + + it('ignores url ids', () => { + const cellA = createStyledCell('A', UnderlineStyle.SINGLE, 45); + const cellB = createStyledCell('B', UnderlineStyle.SINGLE, 45); + cellA.extended.urlId = 1; + cellB.extended.urlId = 2; + cellA.updateExtended(); + cellB.updateExtended(); + + assert.equal(cellA.attributesEquals(cellB), true); + }); + }); +}); diff --git a/src/common/buffer/CellData.ts b/src/common/buffer/CellData.ts index 9454c553..43c4c594 100644 --- a/src/common/buffer/CellData.ts +++ b/src/common/buffer/CellData.ts @@ -7,6 +7,7 @@ import { CharData, ICellData, IExtendedAttrs } from 'common/Types'; import { stringFromCodePoint } from 'common/input/TextDecoder'; import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, CHAR_DATA_ATTR_INDEX, Content } from 'common/buffer/Constants'; import { AttributeData, ExtendedAttrs } from 'common/buffer/AttributeData'; +import type { IBufferCell as IBufferCellApi } from '@xterm/xterm'; /** * CellData - represents a single Cell in the terminal buffer. @@ -91,4 +92,60 @@ export class CellData extends AttributeData implements ICellData { public getAsCharData(): CharData { return [this.fg, this.getChars(), this.getWidth(), this.getCode()]; } + + public attributesEquals(other: IBufferCellApi): boolean { + if (this.getFgColorMode() !== other.getFgColorMode() || this.getFgColor() !== other.getFgColor()) { + return false; + } + if (this.getBgColorMode() !== other.getBgColorMode() || this.getBgColor() !== other.getBgColor()) { + return false; + } + if (this.isInverse() !== other.isInverse()) { + return false; + } + if (this.isBold() !== other.isBold()) { + return false; + } + if (this.isUnderline() !== other.isUnderline()) { + return false; + } + if (this.isUnderline()) { + if (this.getUnderlineStyle() !== other.getUnderlineStyle()) { + return false; + } + const thisDefault = this.isUnderlineColorDefault(); + const otherDefault = other.isUnderlineColorDefault(); + if (!(thisDefault && otherDefault)) { + if (thisDefault !== otherDefault) { + return false; + } + if (this.getUnderlineColor() !== other.getUnderlineColor()) { + return false; + } + if (this.getUnderlineColorMode() !== other.getUnderlineColorMode()) { + return false; + } + } + } + if (this.isOverline() !== other.isOverline()) { + return false; + } + if (this.isBlink() !== other.isBlink()) { + return false; + } + if (this.isInvisible() !== other.isInvisible()) { + return false; + } + if (this.isItalic() !== other.isItalic()) { + return false; + } + if (this.isDim() !== other.isDim()) { + return false; + } + if (this.isStrikethrough() !== other.isStrikethrough()) { + return false; + } + return true; + } + } diff --git a/src/common/input/KittyKeyboard.test.ts b/src/common/input/KittyKeyboard.test.ts index af28a5b2..e495ac59 100644 --- a/src/common/input/KittyKeyboard.test.ts +++ b/src/common/input/KittyKeyboard.test.ts @@ -474,11 +474,25 @@ describe('KittyKeyboard', () => { }); it('modifier key release includes its own bit cleared', () => { - const result = kitty.evaluate(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: false }), flags, KittyKeyboardEventType.RELEASE); + const result = kitty.evaluate(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: false }), flags | KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES, KittyKeyboardEventType.RELEASE); assert.strictEqual(result.key, '\x1b[57441;1:3u'); }); }); + describe('modifier-only reporting', () => { + const flags = KittyKeyboardFlags.REPORT_EVENT_TYPES; + + it('does not report modifier press without REPORT_ALL_KEYS_AS_ESCAPE_CODES', () => { + const result = kitty.evaluate(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: true }), flags); + assert.strictEqual(result.key, undefined); + }); + + it('does not report modifier release without REPORT_ALL_KEYS_AS_ESCAPE_CODES', () => { + const result = kitty.evaluate(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: false }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, undefined); + }); + }); + describe('REPORT_ALL_KEYS_AS_ESCAPE_CODES flag', () => { const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES; diff --git a/src/common/input/KittyKeyboard.ts b/src/common/input/KittyKeyboard.ts index c9db3dad..b296695c 100644 --- a/src/common/input/KittyKeyboard.ts +++ b/src/common/input/KittyKeyboard.ts @@ -418,7 +418,7 @@ export class KittyKeyboard { return result; } - if (isMod && !(flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES) && !reportEventTypes) { + if (isMod && !(flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES)) { return result; } diff --git a/src/common/public/BufferLineApiView.ts b/src/common/public/BufferLineApiView.ts index 560dd0bf..0a747fa4 100644 --- a/src/common/public/BufferLineApiView.ts +++ b/src/common/public/BufferLineApiView.ts @@ -18,10 +18,10 @@ export class BufferLineApiView implements IBufferLineApi { } if (cell) { - this._line.loadCell(x, cell as ICellData); + this._line.loadCell(x, cell as unknown as ICellData); return cell; } - return this._line.loadCell(x, new CellData()); + return this._line.loadCell(x, new CellData()) as unknown as IBufferCellApi; } public translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string { return this._line.translateToString(trimRight, startColumn, endColumn); diff --git a/src/common/services/BufferService.ts b/src/common/services/BufferService.ts index 2e6227fe..6a7ce2b3 100644 --- a/src/common/services/BufferService.ts +++ b/src/common/services/BufferService.ts @@ -7,7 +7,7 @@ import { Disposable } from 'common/Lifecycle'; import { IAttributeData, IBufferLine } from 'common/Types'; import { BufferSet } from 'common/buffer/BufferSet'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; -import { IBufferService, IOptionsService, type IBufferResizeEvent } from 'common/services/Services'; +import { IBufferService, ILogService, IOptionsService, type IBufferResizeEvent } from 'common/services/Services'; import { Emitter } from 'common/Event'; export const MINIMUM_COLS = 2; // Less than 2 can mess with wide chars @@ -32,11 +32,14 @@ export class BufferService extends Disposable implements IBufferService { /** An IBufferline to clone/copy from for new blank lines */ private _cachedBlankLine: IBufferLine | undefined; - constructor(@IOptionsService optionsService: IOptionsService) { + constructor( + @IOptionsService optionsService: IOptionsService, + @ILogService logService: ILogService + ) { super(); this.cols = Math.max(optionsService.rawOptions.cols || 0, MINIMUM_COLS); this.rows = Math.max(optionsService.rawOptions.rows || 0, MINIMUM_ROWS); - this.buffers = this._register(new BufferSet(optionsService, this)); + this.buffers = this._register(new BufferSet(optionsService, this, logService)); this._register(this.buffers.onBufferActivate(e => { this._onScroll.fire(e.activeBuffer.ydisp); })); diff --git a/src/common/services/DecorationService.test.ts b/src/common/services/DecorationService.test.ts index 365be087..95c7c0e2 100644 --- a/src/common/services/DecorationService.test.ts +++ b/src/common/services/DecorationService.test.ts @@ -8,6 +8,7 @@ import { DecorationService } from './DecorationService'; import { IMarker } from 'common/Types'; import { Disposable } from 'common/Lifecycle'; import { Emitter } from 'common/Event'; +import { MockLogService } from 'common/TestUtils.test'; function createFakeMarker(line: number): IMarker { return Object.freeze(new class extends Disposable { @@ -22,7 +23,7 @@ const fakeMarker: IMarker = createFakeMarker(1); describe('DecorationService', () => { it('should set isDisposed to true after dispose', () => { - const service = new DecorationService(); + const service = new DecorationService(new MockLogService()); const decoration = service.registerDecoration({ marker: fakeMarker }); @@ -34,7 +35,7 @@ describe('DecorationService', () => { describe('forEachDecorationAtCell', () => { it('should find decoration at its marker line', () => { - const service = new DecorationService(); + const service = new DecorationService(new MockLogService()); const decoration = service.registerDecoration({ marker: createFakeMarker(5), width: 10 @@ -47,7 +48,7 @@ describe('DecorationService', () => { }); it('should find decoration with height > 1 on subsequent lines', () => { - const service = new DecorationService(); + const service = new DecorationService(new MockLogService()); const decoration = service.registerDecoration({ marker: createFakeMarker(5), width: 10, @@ -73,7 +74,7 @@ describe('DecorationService', () => { }); it('should not find decoration outside its x range', () => { - const service = new DecorationService(); + const service = new DecorationService(new MockLogService()); const decoration = service.registerDecoration({ marker: createFakeMarker(5), x: 5, @@ -102,7 +103,7 @@ describe('DecorationService', () => { describe('getDecorationsAtCell', () => { it('should find decoration with height > 1 on subsequent lines', () => { - const service = new DecorationService(); + const service = new DecorationService(new MockLogService()); const decoration = service.registerDecoration({ marker: createFakeMarker(5), width: 10, diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts index 92e06089..133e1a98 100644 --- a/src/common/services/DecorationService.ts +++ b/src/common/services/DecorationService.ts @@ -5,7 +5,7 @@ import { css } from 'common/Color'; import { Disposable, DisposableStore, toDisposable } from 'common/Lifecycle'; -import { IDecorationService, IInternalDecoration } from 'common/services/Services'; +import { IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services'; import { SortedList } from 'common/SortedList'; import { IColor } from 'common/Types'; import { IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm'; @@ -25,7 +25,7 @@ export class DecorationService extends Disposable implements IDecorationService * while marker line values do change, they should all change by the same amount so this should * never become out of order. */ - private readonly _decorations: SortedList = new SortedList(e => e?.marker.line); + private readonly _decorations: SortedList; private readonly _onDecorationRegistered = this._register(new Emitter()); public readonly onDecorationRegistered = this._onDecorationRegistered.event; @@ -34,9 +34,11 @@ export class DecorationService extends Disposable implements IDecorationService public get decorations(): IterableIterator { return this._decorations.values(); } - constructor() { + constructor(@ILogService private readonly _logService: ILogService) { super(); + this._decorations = new SortedList(e => e?.marker.line, this._logService); + this._register(toDisposable(() => this.reset())); } diff --git a/src/common/services/OptionsService.ts b/src/common/services/OptionsService.ts index 23c523d4..ec647aaa 100644 --- a/src/common/services/OptionsService.ts +++ b/src/common/services/OptionsService.ts @@ -32,7 +32,7 @@ export const DEFAULT_OPTIONS: Readonly> = { logLevel: 'info', logger: null, scrollback: 1000, - scrollbar: {}, + scrollbar: { showScrollbar: true }, scrollOnEraseInDisplay: false, scrollOnUserInput: true, scrollSensitivity: 1, @@ -55,7 +55,6 @@ export const DEFAULT_OPTIONS: Readonly> = { altClickMovesCursor: true, convertEol: false, termName: 'xterm', - overviewRuler: {}, quirks: {}, vtExtensions: {} }; diff --git a/src/common/services/OscLinkService.test.ts b/src/common/services/OscLinkService.test.ts index 5000e8e2..cc6c2228 100644 --- a/src/common/services/OscLinkService.test.ts +++ b/src/common/services/OscLinkService.test.ts @@ -9,6 +9,7 @@ import { BufferService } from 'common/services/BufferService'; import { OptionsService } from 'common/services/OptionsService'; import { OscLinkService } from 'common/services/OscLinkService'; import { IBufferService, IOptionsService, IOscLinkService } from 'common/services/Services'; +import { MockLogService } from 'common/TestUtils.test'; describe('OscLinkService', () => { describe('constructor', () => { @@ -17,7 +18,7 @@ describe('OscLinkService', () => { let oscLinkService: IOscLinkService; beforeEach(() => { optionsService = new OptionsService({ rows: 3, cols: 10 }); - bufferService = new BufferService(optionsService); + bufferService = new BufferService(optionsService, new MockLogService()); oscLinkService = new OscLinkService(bufferService); }); diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index 6a240560..b40f16fd 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -265,7 +265,6 @@ export interface ITerminalOptions { windowsPty?: IWindowsPty; windowOptions?: IWindowOptions; wordSeparator?: string; - overviewRuler?: IOverviewRulerOptions; quirks?: ITerminalQuirks; scrollbar?: IScrollbarOptions; scrollOnEraseInDisplay?: boolean; @@ -311,7 +310,10 @@ export interface ITerminalQuirks { } export interface IScrollbarOptions { + showScrollbar?: boolean; showArrows?: boolean; + width?: number; + overviewRuler?: IOverviewRulerOptions; } export interface IVtExtensions { diff --git a/test/playwright/Terminal.test.ts b/test/playwright/Terminal.test.ts index 9985fe67..2d1133cc 100644 --- a/test/playwright/Terminal.test.ts +++ b/test/playwright/Terminal.test.ts @@ -816,7 +816,7 @@ test.describe('API Integration Tests', () => { }); test.describe('overviewRulerDecorations', () => { test('should not add an overview ruler when width is not set', async () => { - await openTerminal(ctx); + await openTerminal(ctx, { scrollbar: { overviewRuler: {} } }); await ctx.page.evaluate(`window.marker1 = window.term.registerMarker(1)`); await ctx.page.evaluate(`window.marker2 = window.term.registerMarker(2)`); await ctx.page.evaluate(`window.term.registerDecoration({ marker: window.marker1, overviewRulerOptions: { color: 'red', position: 'full' } })`); @@ -824,7 +824,7 @@ test.describe('API Integration Tests', () => { await pollFor(ctx.page, `document.querySelectorAll('.xterm-decoration-overview-ruler').length`, 0); }); test('should add an overview ruler when width is set', async () => { - await openTerminal(ctx, { overviewRuler: { width: 15 } }); + await openTerminal(ctx, { scrollbar: { width: 15, overviewRuler: {} } }); await ctx.page.evaluate(`window.marker1 = window.term.registerMarker(1)`); await ctx.page.evaluate(`window.marker2 = window.term.registerMarker(2)`); await ctx.page.evaluate(`window.term.registerDecoration({ marker: window.marker1, overviewRulerOptions: { color: 'red', position: 'full' } })`); diff --git a/test/playwright/TestUtils.ts b/test/playwright/TestUtils.ts index 59bcc1e8..f1569d41 100644 --- a/test/playwright/TestUtils.ts +++ b/test/playwright/TestUtils.ts @@ -115,6 +115,7 @@ type TerminalProxyAsyncMethodOverrides = 'hasSelection' | 'getSelection' | 'getS type TerminalProxyCustomOverrides = 'buffer' | 'dimensions' | ( // The below are not implemented yet 'element' | + 'screenElement' | 'textarea' | 'markers' | 'unicode' | diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index 9cd46c07..de8d06c4 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -1218,6 +1218,26 @@ declare module '@xterm/headless' { /** Whether the cell has the default attribute (no color or style). */ isAttributeDefault(): boolean; + + /** Gets the underline style. */ + getUnderlineStyle(): number; + /** Gets the underline color number. */ + getUnderlineColor(): number; + /** Gets the underline color mode. */ + getUnderlineColorMode(): number; + /** Whether the cell is using the RGB underline color mode. */ + isUnderlineColorRGB(): boolean; + /** Whether the cell is using the palette underline color mode. */ + isUnderlineColorPalette(): boolean; + /** Whether the cell is using the default underline color mode. */ + isUnderlineColorDefault(): boolean; + + /** + * Compares the cell's attributes (colors and styles) with another cell. + * This does not compare the cell's content and excludes URL ids and + * underline variant offsets. + */ + attributesEquals(other: IBufferCell): boolean; } /** diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 62de0ab2..c8e08f50 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -206,12 +206,6 @@ declare module '@xterm/xterm' { */ minimumContrastRatio?: number; - /** - * Controls the visibility and style of the overview ruler which visualizes - * decorations underneath the scroll bar. - */ - overviewRuler?: IOverviewRulerOptions; - /** * Control various quirks features that are either non-standard or standard * in but generally rejected in modern terminals. @@ -399,8 +393,8 @@ declare module '@xterm/xterm' { scrollbarSliderActiveBackground?: string; /** * The border color of the overview ruler. This visually separates the - * terminal from the scroll bar when {@link IOverviewRulerOptions.width} is - * set. When this is not set it defaults to black (`#000000`). + * terminal from the scroll bar when {@link IScrollbarOptions.width} is set. + * When this is not set it defaults to black (`#000000`). */ overviewRulerBorder?: string; /** ANSI black (eg. `\x1b[30m`) */ @@ -688,8 +682,8 @@ declare module '@xterm/xterm' { /** * When defined, renders the decoration in the overview ruler to the right - * of the terminal. {@link IOverviewRulerOptions.width} must be set in order - * to see the overview ruler. + * of the terminal. {@link IScrollbarOptions.width} must be set in order to + * see the overview ruler. * @param color The color of the decoration. * @param position The position of the decoration. */ @@ -712,15 +706,10 @@ declare module '@xterm/xterm' { tooMuchOutput: string; } + /** + * Options for configuring the overview ruler rendered beside the scrollbar. + */ export interface IOverviewRulerOptions { - /** - * When defined, renders decorations in the overview ruler to the right of - * the terminal. This must be set in order to see the overview ruler. - * @param color The color of the decoration. - * @param position The position of the decoration. - */ - width?: number; - /** * Whether to show the top border of the overview ruler, which uses the * {@link ITheme.overviewRulerBorder} color. @@ -738,11 +727,28 @@ declare module '@xterm/xterm' { * Options for configuring the scrollbar. */ export interface IScrollbarOptions { + /** + * Whether to show the scrollbar. When false, this supersedes + * {@link IScrollbarOptions.width}. Defaults to true. + */ + showScrollbar?: boolean; /** * Whether to show arrows at the top and bottom of the scrollbar. Defaults * to false. */ showArrows?: boolean; + + /** + * The width of the scrollbar and overview ruler in CSS pixels. When set, + * this enables the overview ruler. + */ + width?: number; + + /** + * Controls the visibility and style of the overview ruler which visualizes + * decorations underneath the scroll bar. + */ + overviewRuler?: IOverviewRulerOptions; } /** @@ -911,6 +917,12 @@ declare module '@xterm/xterm' { */ readonly element: HTMLElement | undefined; + /** + * The screen element containing the terminal's canvas rendering layers and + * decorations, excluding the viewport and the scrollbar. + */ + readonly screenElement: HTMLElement | undefined; + /** * The textarea that accepts input for the terminal. */ @@ -1841,6 +1853,26 @@ declare module '@xterm/xterm' { /** Whether the cell has the default attribute (no color or style). */ isAttributeDefault(): boolean; + + /** Gets the underline style. */ + getUnderlineStyle(): number; + /** Gets the underline color number. */ + getUnderlineColor(): number; + /** Gets the underline color mode. */ + getUnderlineColorMode(): number; + /** Whether the cell is using the RGB underline color mode. */ + isUnderlineColorRGB(): boolean; + /** Whether the cell is using the palette underline color mode. */ + isUnderlineColorPalette(): boolean; + /** Whether the cell is using the default underline color mode. */ + isUnderlineColorDefault(): boolean; + + /** + * Compares the cell's attributes (colors and styles) with another cell. + * This does not compare the cell's content and excludes URL ids and + * underline variant offsets. + */ + attributesEquals(other: IBufferCell): boolean; } /**