From 3f39edb0253b2766687ca0d96078846c817e33c2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 30 May 2026 16:13:32 +0000 Subject: [PATCH] Restore source files after removing only build artifacts Co-authored-by: Daniel Imms --- src/common/input/UnicodeV6.test.ts | 185 +++++++ src/common/input/UnicodeV6.ts | 145 ++++++ src/common/parser/Constants.ts | 68 +++ src/common/parser/Types.ts | 309 +++++++++++ src/common/services/CircularList.test.ts | 260 ++++++++++ src/common/services/CircularList.ts | 241 +++++++++ src/common/services/OptionsService.test.ts | 150 ++++++ src/common/services/OptionsService.ts | 219 ++++++++ src/common/services/ServiceRegistry.ts | 51 ++ src/common/services/Services.ts | 402 +++++++++++++++ src/common/services/TaskQueue.ts | 175 +++++++ src/common/services/Types.ts | 570 +++++++++++++++++++++ src/common/services/UnicodeService.test.ts | 53 ++ src/common/services/UnicodeService.ts | 111 ++++ 14 files changed, 2939 insertions(+) create mode 100644 src/common/input/UnicodeV6.test.ts create mode 100644 src/common/input/UnicodeV6.ts create mode 100644 src/common/parser/Constants.ts create mode 100644 src/common/parser/Types.ts create mode 100644 src/common/services/CircularList.test.ts create mode 100644 src/common/services/CircularList.ts create mode 100644 src/common/services/OptionsService.test.ts create mode 100644 src/common/services/OptionsService.ts create mode 100644 src/common/services/ServiceRegistry.ts create mode 100644 src/common/services/Services.ts create mode 100644 src/common/services/TaskQueue.ts create mode 100644 src/common/services/Types.ts create mode 100644 src/common/services/UnicodeService.test.ts create mode 100644 src/common/services/UnicodeService.ts diff --git a/src/common/input/UnicodeV6.test.ts b/src/common/input/UnicodeV6.test.ts new file mode 100644 index 000000000..98dc97dc2 --- /dev/null +++ b/src/common/input/UnicodeV6.test.ts @@ -0,0 +1,185 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { UnicodeV6 } from 'common/input/UnicodeV6'; + +it('wcwidth should match all values from the old implementation', function(): void { + // new implementation + const wcwidthNew = (new UnicodeV6()).wcwidth; + + // old implementation + const wcwidthOld = (function(opts: {nul: number, control: number}): (ucs: number) => number { + // extracted from https://www.cl.cam.ac.uk/%7Emgk25/ucs/wcwidth.c + // combining characters + const COMBINING_BMP = [ + [0x0300, 0x036F], [0x0483, 0x0486], [0x0488, 0x0489], + [0x0591, 0x05BD], [0x05BF, 0x05BF], [0x05C1, 0x05C2], + [0x05C4, 0x05C5], [0x05C7, 0x05C7], [0x0600, 0x0603], + [0x0610, 0x0615], [0x064B, 0x065E], [0x0670, 0x0670], + [0x06D6, 0x06E4], [0x06E7, 0x06E8], [0x06EA, 0x06ED], + [0x070F, 0x070F], [0x0711, 0x0711], [0x0730, 0x074A], + [0x07A6, 0x07B0], [0x07EB, 0x07F3], [0x0901, 0x0902], + [0x093C, 0x093C], [0x0941, 0x0948], [0x094D, 0x094D], + [0x0951, 0x0954], [0x0962, 0x0963], [0x0981, 0x0981], + [0x09BC, 0x09BC], [0x09C1, 0x09C4], [0x09CD, 0x09CD], + [0x09E2, 0x09E3], [0x0A01, 0x0A02], [0x0A3C, 0x0A3C], + [0x0A41, 0x0A42], [0x0A47, 0x0A48], [0x0A4B, 0x0A4D], + [0x0A70, 0x0A71], [0x0A81, 0x0A82], [0x0ABC, 0x0ABC], + [0x0AC1, 0x0AC5], [0x0AC7, 0x0AC8], [0x0ACD, 0x0ACD], + [0x0AE2, 0x0AE3], [0x0B01, 0x0B01], [0x0B3C, 0x0B3C], + [0x0B3F, 0x0B3F], [0x0B41, 0x0B43], [0x0B4D, 0x0B4D], + [0x0B56, 0x0B56], [0x0B82, 0x0B82], [0x0BC0, 0x0BC0], + [0x0BCD, 0x0BCD], [0x0C3E, 0x0C40], [0x0C46, 0x0C48], + [0x0C4A, 0x0C4D], [0x0C55, 0x0C56], [0x0CBC, 0x0CBC], + [0x0CBF, 0x0CBF], [0x0CC6, 0x0CC6], [0x0CCC, 0x0CCD], + [0x0CE2, 0x0CE3], [0x0D41, 0x0D43], [0x0D4D, 0x0D4D], + [0x0DCA, 0x0DCA], [0x0DD2, 0x0DD4], [0x0DD6, 0x0DD6], + [0x0E31, 0x0E31], [0x0E34, 0x0E3A], [0x0E47, 0x0E4E], + [0x0EB1, 0x0EB1], [0x0EB4, 0x0EB9], [0x0EBB, 0x0EBC], + [0x0EC8, 0x0ECD], [0x0F18, 0x0F19], [0x0F35, 0x0F35], + [0x0F37, 0x0F37], [0x0F39, 0x0F39], [0x0F71, 0x0F7E], + [0x0F80, 0x0F84], [0x0F86, 0x0F87], [0x0F90, 0x0F97], + [0x0F99, 0x0FBC], [0x0FC6, 0x0FC6], [0x102D, 0x1030], + [0x1032, 0x1032], [0x1036, 0x1037], [0x1039, 0x1039], + [0x1058, 0x1059], [0x1160, 0x11FF], [0x135F, 0x135F], + [0x1712, 0x1714], [0x1732, 0x1734], [0x1752, 0x1753], + [0x1772, 0x1773], [0x17B4, 0x17B5], [0x17B7, 0x17BD], + [0x17C6, 0x17C6], [0x17C9, 0x17D3], [0x17DD, 0x17DD], + [0x180B, 0x180D], [0x18A9, 0x18A9], [0x1920, 0x1922], + [0x1927, 0x1928], [0x1932, 0x1932], [0x1939, 0x193B], + [0x1A17, 0x1A18], [0x1B00, 0x1B03], [0x1B34, 0x1B34], + [0x1B36, 0x1B3A], [0x1B3C, 0x1B3C], [0x1B42, 0x1B42], + [0x1B6B, 0x1B73], [0x1DC0, 0x1DCA], [0x1DFE, 0x1DFF], + [0x200B, 0x200F], [0x202A, 0x202E], [0x2060, 0x2063], + [0x206A, 0x206F], [0x20D0, 0x20EF], [0x302A, 0x302F], + [0x3099, 0x309A], [0xA806, 0xA806], [0xA80B, 0xA80B], + [0xA825, 0xA826], [0xFB1E, 0xFB1E], [0xFE00, 0xFE0F], + [0xFE20, 0xFE23], [0xFEFF, 0xFEFF], [0xFFF9, 0xFFFB] + ]; + const COMBINING_HIGH = [ + [0x10A01, 0x10A03], [0x10A05, 0x10A06], [0x10A0C, 0x10A0F], + [0x10A38, 0x10A3A], [0x10A3F, 0x10A3F], [0x1D167, 0x1D169], + [0x1D173, 0x1D182], [0x1D185, 0x1D18B], [0x1D1AA, 0x1D1AD], + [0x1D242, 0x1D244], [0xE0001, 0xE0001], [0xE0020, 0xE007F], + [0xE0100, 0xE01EF] + ]; + // binary search + function bisearch(ucs: number, data: number[][]): boolean { + let min = 0; + let max = data.length - 1; + let mid; + if (ucs < data[0][0] || ucs > data[max][1]) { + return false; + } + while (max >= min) { + mid = (min + max) >> 1; + if (ucs > data[mid][1]) { + min = mid + 1; + } else if (ucs < data[mid][0]) { + max = mid - 1; + } else { + return true; + } + } + return false; + } + function wcwidthBMP(ucs: number): number { + // test for 8-bit control characters + if (ucs === 0) { + return opts.nul; + } + if (ucs < 32 || (ucs >= 0x7f && ucs < 0xa0)) { + return opts.control; + } + // binary search in table of non-spacing characters + if (bisearch(ucs, COMBINING_BMP)) { + return 0; + } + // if we arrive here, ucs is not a combining or C0/C1 control character + if (isWideBMP(ucs)) { + return 2; + } + return 1; + } + function isWideBMP(ucs: number): boolean { + return ( + ucs >= 0x1100 && ( + ucs <= 0x115f || // Hangul Jamo init. consonants + ucs === 0x2329 || + ucs === 0x232a || + (ucs >= 0x2e80 && ucs <= 0xa4cf && ucs !== 0x303f) || // CJK..Yi + (ucs >= 0xac00 && ucs <= 0xd7a3) || // Hangul Syllables + (ucs >= 0xf900 && ucs <= 0xfaff) || // CJK Compat Ideographs + (ucs >= 0xfe10 && ucs <= 0xfe19) || // Vertical forms + (ucs >= 0xfe30 && ucs <= 0xfe6f) || // CJK Compat Forms + (ucs >= 0xff00 && ucs <= 0xff60) || // Fullwidth Forms + (ucs >= 0xffe0 && ucs <= 0xffe6))); + } + function wcwidthHigh(ucs: number): 0 | 1 | 2 { + if (bisearch(ucs, COMBINING_HIGH)) { + return 0; + } + if ((ucs >= 0x20000 && ucs <= 0x2fffd) || (ucs >= 0x30000 && ucs <= 0x3fffd)) { + return 2; + } + return 1; + } + const control = opts.control | 0; + let table: number[] | Uint32Array | undefined; + function initTable(): number[] | Uint32Array { + // lookup table for BMP + const CODEPOINTS = 65536; // BMP holds 65536 codepoints + const BITWIDTH = 2; // a codepoint can have a width of 0, 1 or 2 + const ITEMSIZE = 32; // using uint32_t + const CONTAINERSIZE = CODEPOINTS * BITWIDTH / ITEMSIZE; + const CODEPOINTS_PER_ITEM = ITEMSIZE / BITWIDTH; + table = (typeof Uint32Array === 'undefined') + ? new Array(CONTAINERSIZE) + : new Uint32Array(CONTAINERSIZE); + for (let i = 0; i < CONTAINERSIZE; ++i) { + let num = 0; + let pos = CODEPOINTS_PER_ITEM; + while (pos--) { + num = (num << 2) | wcwidthBMP(CODEPOINTS_PER_ITEM * i + pos); + } + table[i] = num; + } + return table; + } + // get width from lookup table + // position in container : num / CODEPOINTS_PER_ITEM + // ==> n = table[Math.floor(num / 16)] + // ==> n = table[num >> 4] + // 16 codepoints per number: FFEEDDCCBBAA99887766554433221100 + // position in number : (num % CODEPOINTS_PER_ITEM) * BITWIDTH + // ==> m = (n % 16) * 2 + // ==> m = (num & 15) << 1 + // right shift to position m + // ==> n = n >> m e.g. m=12 000000000000FFEEDDCCBBAA99887766 + // we are only interested in 2 LSBs, cut off higher bits + // ==> n = n & 3 e.g. 000000000000000000000000000000XX + return (num: number): number => { + num |= 0; // get asm.js like optimization under V8 + if (num < 32) { + return control | 0; + } + if (num < 127) { + return 1; + } + const t = table ?? initTable(); + if (num < 65536) { + return t[num >> 4] >> ((num & 15) << 1) & 3; + } + // do a full search for high codepoints + return wcwidthHigh(num); + }; + })({nul: 0, control: 0}); // configurable options + + // test full BMP range old vs new implmenetation + for (let i = 0; i < 65536; ++i) { + assert.equal(wcwidthNew(i), wcwidthOld(i), `mismatch for i: ${i}`); + } +}); diff --git a/src/common/input/UnicodeV6.ts b/src/common/input/UnicodeV6.ts new file mode 100644 index 000000000..83265f705 --- /dev/null +++ b/src/common/input/UnicodeV6.ts @@ -0,0 +1,145 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ +import { IUnicodeVersionProvider, UnicodeCharProperties, UnicodeCharWidth } from 'common/services/Services'; +import { UnicodeService } from 'common/services/UnicodeService'; + +const BMP_COMBINING = [ + [0x0300, 0x036F], [0x0483, 0x0486], [0x0488, 0x0489], + [0x0591, 0x05BD], [0x05BF, 0x05BF], [0x05C1, 0x05C2], + [0x05C4, 0x05C5], [0x05C7, 0x05C7], [0x0600, 0x0603], + [0x0610, 0x0615], [0x064B, 0x065E], [0x0670, 0x0670], + [0x06D6, 0x06E4], [0x06E7, 0x06E8], [0x06EA, 0x06ED], + [0x070F, 0x070F], [0x0711, 0x0711], [0x0730, 0x074A], + [0x07A6, 0x07B0], [0x07EB, 0x07F3], [0x0901, 0x0902], + [0x093C, 0x093C], [0x0941, 0x0948], [0x094D, 0x094D], + [0x0951, 0x0954], [0x0962, 0x0963], [0x0981, 0x0981], + [0x09BC, 0x09BC], [0x09C1, 0x09C4], [0x09CD, 0x09CD], + [0x09E2, 0x09E3], [0x0A01, 0x0A02], [0x0A3C, 0x0A3C], + [0x0A41, 0x0A42], [0x0A47, 0x0A48], [0x0A4B, 0x0A4D], + [0x0A70, 0x0A71], [0x0A81, 0x0A82], [0x0ABC, 0x0ABC], + [0x0AC1, 0x0AC5], [0x0AC7, 0x0AC8], [0x0ACD, 0x0ACD], + [0x0AE2, 0x0AE3], [0x0B01, 0x0B01], [0x0B3C, 0x0B3C], + [0x0B3F, 0x0B3F], [0x0B41, 0x0B43], [0x0B4D, 0x0B4D], + [0x0B56, 0x0B56], [0x0B82, 0x0B82], [0x0BC0, 0x0BC0], + [0x0BCD, 0x0BCD], [0x0C3E, 0x0C40], [0x0C46, 0x0C48], + [0x0C4A, 0x0C4D], [0x0C55, 0x0C56], [0x0CBC, 0x0CBC], + [0x0CBF, 0x0CBF], [0x0CC6, 0x0CC6], [0x0CCC, 0x0CCD], + [0x0CE2, 0x0CE3], [0x0D41, 0x0D43], [0x0D4D, 0x0D4D], + [0x0DCA, 0x0DCA], [0x0DD2, 0x0DD4], [0x0DD6, 0x0DD6], + [0x0E31, 0x0E31], [0x0E34, 0x0E3A], [0x0E47, 0x0E4E], + [0x0EB1, 0x0EB1], [0x0EB4, 0x0EB9], [0x0EBB, 0x0EBC], + [0x0EC8, 0x0ECD], [0x0F18, 0x0F19], [0x0F35, 0x0F35], + [0x0F37, 0x0F37], [0x0F39, 0x0F39], [0x0F71, 0x0F7E], + [0x0F80, 0x0F84], [0x0F86, 0x0F87], [0x0F90, 0x0F97], + [0x0F99, 0x0FBC], [0x0FC6, 0x0FC6], [0x102D, 0x1030], + [0x1032, 0x1032], [0x1036, 0x1037], [0x1039, 0x1039], + [0x1058, 0x1059], [0x1160, 0x11FF], [0x135F, 0x135F], + [0x1712, 0x1714], [0x1732, 0x1734], [0x1752, 0x1753], + [0x1772, 0x1773], [0x17B4, 0x17B5], [0x17B7, 0x17BD], + [0x17C6, 0x17C6], [0x17C9, 0x17D3], [0x17DD, 0x17DD], + [0x180B, 0x180D], [0x18A9, 0x18A9], [0x1920, 0x1922], + [0x1927, 0x1928], [0x1932, 0x1932], [0x1939, 0x193B], + [0x1A17, 0x1A18], [0x1B00, 0x1B03], [0x1B34, 0x1B34], + [0x1B36, 0x1B3A], [0x1B3C, 0x1B3C], [0x1B42, 0x1B42], + [0x1B6B, 0x1B73], [0x1DC0, 0x1DCA], [0x1DFE, 0x1DFF], + [0x200B, 0x200F], [0x202A, 0x202E], [0x2060, 0x2063], + [0x206A, 0x206F], [0x20D0, 0x20EF], [0x302A, 0x302F], + [0x3099, 0x309A], [0xA806, 0xA806], [0xA80B, 0xA80B], + [0xA825, 0xA826], [0xFB1E, 0xFB1E], [0xFE00, 0xFE0F], + [0xFE20, 0xFE23], [0xFEFF, 0xFEFF], [0xFFF9, 0xFFFB] +]; +const HIGH_COMBINING = [ + [0x10A01, 0x10A03], [0x10A05, 0x10A06], [0x10A0C, 0x10A0F], + [0x10A38, 0x10A3A], [0x10A3F, 0x10A3F], [0x1D167, 0x1D169], + [0x1D173, 0x1D182], [0x1D185, 0x1D18B], [0x1D1AA, 0x1D1AD], + [0x1D242, 0x1D244], [0xE0001, 0xE0001], [0xE0020, 0xE007F], + [0xE0100, 0xE01EF] +]; + +// BMP lookup table, lazy initialized during first addon loading +let table: Uint8Array; + +function bisearch(ucs: number, data: number[][]): boolean { + let min = 0; + let max = data.length - 1; + let mid; + if (ucs < data[0][0] || ucs > data[max][1]) { + return false; + } + while (max >= min) { + mid = (min + max) >> 1; + if (ucs > data[mid][1]) { + min = mid + 1; + } else if (ucs < data[mid][0]) { + max = mid - 1; + } else { + return true; + } + } + return false; +} + +export class UnicodeV6 implements IUnicodeVersionProvider { + public readonly version = '6'; + + constructor() { + // init lookup table once + if (!table) { + table = new Uint8Array(65536); + table.fill(1); + table[0] = 0; + // control chars + table.fill(0, 1, 32); + table.fill(0, 0x7f, 0xa0); + + // apply wide char rules first + // wide chars + table.fill(2, 0x1100, 0x1160); + table[0x2329] = 2; + table[0x232a] = 2; + table.fill(2, 0x2e80, 0xa4d0); + table[0x303f] = 1; // wrongly in last line + + table.fill(2, 0xac00, 0xd7a4); + table.fill(2, 0xf900, 0xfb00); + table.fill(2, 0xfe10, 0xfe1a); + table.fill(2, 0xfe30, 0xfe70); + table.fill(2, 0xff00, 0xff61); + table.fill(2, 0xffe0, 0xffe7); + + // apply combining last to ensure we overwrite + // wrongly wide set chars: + // the original algo evals combining first and falls + // through to wide check so we simply do here the opposite + // combining 0 + for (let r = 0; r < BMP_COMBINING.length; ++r) { + table.fill(0, BMP_COMBINING[r][0], BMP_COMBINING[r][1] + 1); + } + } + } + + public wcwidth(num: number): UnicodeCharWidth { + if (num < 32) return 0; + if (num < 127) return 1; + if (num < 65536) return table[num] as UnicodeCharWidth; + if (bisearch(num, HIGH_COMBINING)) return 0; + if ((num >= 0x20000 && num <= 0x2fffd) || (num >= 0x30000 && num <= 0x3fffd)) return 2; + return 1; + } + + public charProperties(codepoint: number, preceding: UnicodeCharProperties): UnicodeCharProperties { + let width = this.wcwidth(codepoint); + let shouldJoin = width === 0 && preceding !== 0; + if (shouldJoin) { + const oldWidth = UnicodeService.extractWidth(preceding); + if (oldWidth === 0) { + shouldJoin = false; + } else if (oldWidth > width) { + width = oldWidth; + } + } + return UnicodeService.createPropertyValue(0, width, shouldJoin); + } +} diff --git a/src/common/parser/Constants.ts b/src/common/parser/Constants.ts new file mode 100644 index 000000000..cf596c65e --- /dev/null +++ b/src/common/parser/Constants.ts @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +/** + * Internal states of EscapeSequenceParser. + */ +export const enum ParserState { + GROUND = 0, + ESCAPE = 1, + ESCAPE_INTERMEDIATE = 2, + CSI_ENTRY = 3, + CSI_PARAM = 4, + CSI_INTERMEDIATE = 5, + CSI_IGNORE = 6, + SOS_PM_STRING = 7, + OSC_STRING = 8, + DCS_ENTRY = 9, + DCS_PARAM = 10, + DCS_IGNORE = 11, + DCS_INTERMEDIATE = 12, + DCS_PASSTHROUGH = 13, + APC_ENTRY = 14, + APC_INTERMEDIATE = 15, + APC_PASSTHROUGH = 16, + // Number of states, meaning LAST_STATE + 1. + STATE_LENGTH = 17 +} + +/** + * Internal actions of EscapeSequenceParser. + */ +export const enum ParserAction { + IGNORE = 0, + ERROR = 1, + PRINT = 2, + EXECUTE = 3, + OSC_START = 4, + OSC_PUT = 5, + OSC_END = 6, + CSI_DISPATCH = 7, + PARAM = 8, + COLLECT = 9, + ESC_DISPATCH = 10, + CLEAR = 11, + DCS_HOOK = 12, + DCS_PUT = 13, + DCS_UNHOOK = 14, + APC_START = 15, + APC_PUT = 16, + APC_END = 17 +} + +/** + * Internal states of OscParser. + */ +export const enum OscState { + START = 0, + ID = 1, + PAYLOAD = 2, + ABORT = 3 +} + +// payload limit for OSC and DCS +export const enum ParserConstants { + PAYLOAD_LIMIT = 10000000 +} diff --git a/src/common/parser/Types.ts b/src/common/parser/Types.ts new file mode 100644 index 000000000..13f8955a0 --- /dev/null +++ b/src/common/parser/Types.ts @@ -0,0 +1,309 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IDisposable } from 'common/services/Types'; +import { ParserState } from 'common/parser/Constants'; + + +/** sequence params serialized to js arrays */ +export type ParamsArray = (number | number[])[]; + +/** Params constructor type. */ +export interface IParamsConstructor { + new(maxLength: number, maxSubParamsLength: number): IParams; + + /** create params from ParamsArray */ + fromArray(values: ParamsArray): IParams; +} + +/** Interface of Params storage class. */ +export interface IParams { + /** from ctor */ + maxLength: number; + maxSubParamsLength: number; + + /** param values and its length */ + params: Int32Array; + length: number; + + /** methods */ + clone(): IParams; + toArray(): ParamsArray; + reset(): void; + resetZdm(): void; + addParam(value: number): void; + addSubParam(value: number): void; + hasSubParams(idx: number): boolean; + getSubParams(idx: number): Int32Array | null; + getSubParamsAll(): {[idx: number]: Int32Array}; +} + +/** + * Internal state of EscapeSequenceParser. + * Used as argument of the error handler to allow + * introspection at runtime on parse errors. + * Return it with altered values to recover from + * faulty states (not yet supported). + * Set `abort` to `true` to abort the current parsing. + */ +export interface IParsingState { + // position in parse string + position: number; + // actual character code + code: number; + // current parser state + currentState: ParserState; + // collect buffer with intermediate characters + collect: number; + // params buffer + params: IParams; + // should abort (default: false) + abort: boolean; +} + +/** + * Command handler interfaces. + */ + +/** + * CSI handler types. + * Note: `params` is borrowed. + */ +export type CsiHandlerType = (params: IParams) => boolean | Promise; +export type CsiFallbackHandlerType = (ident: number, params: IParams) => void; + +/** + * DCS handler types. + */ +export interface IDcsHandler { + /** + * Called when a DCS command starts. + * Prepare needed data structures here. + * Note: `params` is borrowed. + */ + hook(params: IParams): void; + /** + * Incoming payload chunk. + * Note: `params` is borrowed. + */ + put(data: Uint32Array, start: number, end: number): void; + /** + * End of DCS command. `success` indicates whether the + * command finished normally or got aborted, thus final + * execution of the command should depend on `success`. + * To save memory also cleanup data structures here. + */ + unhook(success: boolean): boolean | Promise; +} +export type DcsFallbackHandlerType = (ident: number, action: 'HOOK' | 'PUT' | 'UNHOOK', payload?: any) => void; + +/** + * ESC handler types. + */ +export type EscHandlerType = () => boolean | Promise; +export type EscFallbackHandlerType = (identifier: number) => void; + +/** + * EXECUTE handler types. + */ +export type ExecuteHandlerType = (ident?: number) => boolean; +export type ExecuteFallbackHandlerType = (ident: number) => void; + +/** + * OSC handler types. + */ +export interface IOscHandler { + /** + * Announces start of this OSC command. + * Prepare needed data structures here. + */ + start(): void; + /** + * Incoming data chunk. + * Note: Data is borrowed. + */ + put(data: Uint32Array, start: number, end: number): void; + /** + * End of OSC command. `success` indicates whether the + * command finished normally or got aborted, thus final + * execution of the command should depend on `success`. + * To save memory also cleanup data structures here. + */ + end(success: boolean): boolean | Promise; +} +export type OscFallbackHandlerType = (ident: number, action: 'START' | 'PUT' | 'END', payload?: any) => void; + +/** + * APC handler types. + */ +export interface IApcHandler { + /** + * Announces start of this APC command. + * Prepare needed data structures here. + */ + start(): void; + /** + * Incoming data chunk. + */ + put(data: Uint32Array, start: number, end: number): void; + /** + * End of APC command. `success` indicates whether the + * command finished normally or got aborted, thus final + * execution of the command should depend on `success`. + * To save memory also cleanup data structures here. + */ + end(success: boolean): boolean | Promise; +} +export type ApcFallbackHandlerType = (ident: number, action: 'START' | 'PUT' | 'END', payload?: any) => void; + +/** + * PRINT handler types. + */ +export type PrintHandlerType = (data: Uint32Array, start: number, end: number) => void; +export type PrintFallbackHandlerType = PrintHandlerType; + + +/** + * EscapeSequenceParser interface. + */ +export interface IEscapeSequenceParser extends IDisposable { + /** + * Preceding grapheme-join-state. + * Used for joining grapheme clusters across calls to `print`. + * Also used by REP to check if repeating a character is allowed. + * It gets reset by the parser for any valid sequence besides text. + */ + precedingJoinState: number; // More specifically: UnicodeJoinProperties + + /** + * Reset the parser to its initial state (handlers are kept). + */ + reset(): void; + + /** + * Parse UTF32 codepoints in `data` up to `length`. + * @param data The data to parse. + */ + parse(data: Uint32Array, length: number, promiseResult?: boolean): void | Promise; + + /** + * Get string from numercial function identifier `ident`. + * Useful in fallback handlers which expose the low level + * numcerical function identifier for debugging purposes. + * Note: A full back translation to `IFunctionIdentifier` + * is not implemented. + */ + identToString(ident: number): string; + + setPrintHandler(handler: PrintHandlerType): void; + clearPrintHandler(): void; + + registerEscHandler(id: IFunctionIdentifier, handler: EscHandlerType): IDisposable; + clearEscHandler(id: IFunctionIdentifier): void; + setEscHandlerFallback(handler: EscFallbackHandlerType): void; + + setExecuteHandler(flag: string, handler: ExecuteHandlerType): void; + clearExecuteHandler(flag: string): void; + setExecuteHandlerFallback(handler: ExecuteFallbackHandlerType): void; + + registerCsiHandler(id: IFunctionIdentifier, handler: CsiHandlerType): IDisposable; + clearCsiHandler(id: IFunctionIdentifier): void; + setCsiHandlerFallback(callback: CsiFallbackHandlerType): void; + + registerDcsHandler(id: IFunctionIdentifier, handler: IDcsHandler): IDisposable; + clearDcsHandler(id: IFunctionIdentifier): void; + setDcsHandlerFallback(handler: DcsFallbackHandlerType): void; + + registerOscHandler(ident: number, handler: IOscHandler): IDisposable; + clearOscHandler(ident: number): void; + setOscHandlerFallback(handler: OscFallbackHandlerType): void; + + registerApcHandler(id: IFunctionIdentifier, handler: IApcHandler): IDisposable; + clearApcHandler(id: IFunctionIdentifier): void; + setApcHandlerFallback(handler: ApcFallbackHandlerType): void; + + setErrorHandler(handler: (state: IParsingState) => IParsingState): void; + clearErrorHandler(): void; +} + +/** + * Subparser interfaces. + * The subparsers are instantiated in `EscapeSequenceParser` and + * called during `EscapeSequenceParser.parse`. + */ +export interface ISubParser extends IDisposable { + reset(): void; + registerHandler(ident: number, handler: T): IDisposable; + clearHandler(ident: number): void; + setHandlerFallback(handler: U): void; + put(data: Uint32Array, start: number, end: number): void; +} + +export interface IOscParser extends ISubParser { + start(): void; + end(success: boolean, promiseResult?: boolean): void | Promise; +} + +export interface IDcsParser extends ISubParser { + hook(ident: number, params: IParams): void; + unhook(success: boolean, promiseResult?: boolean): void | Promise; +} + +export interface IApcParser extends ISubParser { + start(ident: number): void; + end(success: boolean, promiseResult?: boolean): void | Promise; +} + +/** + * Interface to denote a specific ESC, CSI or DCS handler slot. + * The values are used to create an integer respresentation during handler + * regristation before passed to the subparsers as `ident`. + * The integer translation is made to allow a faster handler access + * in `EscapeSequenceParser.parse`. + */ +export interface IFunctionIdentifier { + prefix?: string; + intermediates?: string; + final: string; +} + +export interface IHandlerCollection { + [key: string]: T[]; +} + +/** + * Types for async parser support. + */ + +// type of saved stack state in parser +export const enum ParserStackType { + NONE = 0, + FAIL, + RESET, + CSI, + ESC, + OSC, + DCS, + APC +} + +// aggregate of resumable handler lists +export type ResumableHandlersType = CsiHandlerType[] | EscHandlerType[]; + +// saved stack state of the parser +export interface IParserStackState { + state: ParserStackType; + handlers: ResumableHandlersType; + handlerPos: number; + transition: number; + chunkPos: number; +} + +// saved stack state of subparser (OSC and DCS) +export interface ISubParserStackState { + paused: boolean; + loopPosition: number; + fallThrough: boolean; +} diff --git a/src/common/services/CircularList.test.ts b/src/common/services/CircularList.test.ts new file mode 100644 index 000000000..907577833 --- /dev/null +++ b/src/common/services/CircularList.test.ts @@ -0,0 +1,260 @@ +/** + * Copyright (c) 2016 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { CircularList } from 'common/services/CircularList'; + +describe('CircularList', () => { + describe('push', () => { + it('should push values onto the array', () => { + const list = new CircularList(5); + list.push('1'); + list.push('2'); + list.push('3'); + list.push('4'); + list.push('5'); + assert.equal(list.get(0), '1'); + assert.equal(list.get(1), '2'); + assert.equal(list.get(2), '3'); + assert.equal(list.get(3), '4'); + assert.equal(list.get(4), '5'); + }); + + it('should push old values from the start out of the array when max length is reached', () => { + const list = new CircularList(2); + list.push('1'); + list.push('2'); + assert.equal(list.get(0), '1'); + assert.equal(list.get(1), '2'); + list.push('3'); + assert.equal(list.get(0), '2'); + assert.equal(list.get(1), '3'); + list.push('4'); + assert.equal(list.get(0), '3'); + assert.equal(list.get(1), '4'); + }); + }); + + describe('maxLength', () => { + it('should increase the size of the list', () => { + const list = new CircularList(2); + list.push('1'); + list.push('2'); + assert.equal(list.get(0), '1'); + assert.equal(list.get(1), '2'); + list.maxLength = 4; + list.push('3'); + list.push('4'); + assert.equal(list.get(0), '1'); + assert.equal(list.get(1), '2'); + assert.equal(list.get(2), '3'); + assert.equal(list.get(3), '4'); + list.push('wrapped'); + assert.equal(list.get(0), '2'); + assert.equal(list.get(1), '3'); + assert.equal(list.get(2), '4'); + assert.equal(list.get(3), 'wrapped'); + }); + + it('should return the maximum length of the list', () => { + const list = new CircularList(2); + assert.equal(list.maxLength, 2); + list.push('1'); + list.push('2'); + assert.equal(list.maxLength, 2); + list.push('3'); + assert.equal(list.maxLength, 2); + list.maxLength = 4; + assert.equal(list.maxLength, 4); + }); + }); + + describe('length', () => { + it('should return the current length of the list, capped at the maximum length', () => { + const list = new CircularList(2); + assert.equal(list.length, 0); + list.push('1'); + assert.equal(list.length, 1); + list.push('2'); + assert.equal(list.length, 2); + list.push('3'); + assert.equal(list.length, 2); + }); + }); + + describe('splice', () => { + it('should delete items', () => { + const list = new CircularList(2); + list.push('1'); + list.push('2'); + list.splice(0, 1); + assert.equal(list.length, 1); + assert.equal(list.get(0), '2'); + list.push('3'); + list.splice(1, 1); + assert.equal(list.length, 1); + assert.equal(list.get(0), '2'); + }); + + it('should insert items', () => { + const list = new CircularList(2); + list.push('1'); + list.splice(0, 0, '2'); + assert.equal(list.length, 2); + assert.equal(list.get(0), '2'); + assert.equal(list.get(1), '1'); + list.splice(1, 0, '3'); + assert.equal(list.length, 2); + assert.equal(list.get(0), '3'); + assert.equal(list.get(1), '1'); + }); + + it('should delete items then insert items', () => { + const list = new CircularList(3); + list.push('1'); + list.push('2'); + list.splice(0, 1, '3', '4'); + assert.equal(list.length, 3); + assert.equal(list.get(0), '3'); + assert.equal(list.get(1), '4'); + assert.equal(list.get(2), '2'); + }); + + it('should wrap the array correctly when more items are inserted than deleted', () => { + const list = new CircularList(3); + list.push('1'); + list.push('2'); + list.splice(1, 0, '3', '4'); + assert.equal(list.length, 3); + assert.equal(list.get(0), '3'); + assert.equal(list.get(1), '4'); + assert.equal(list.get(2), '2'); + }); + }); + + describe('trimStart', () => { + it('should remove items from the beginning of the list', () => { + const list = new CircularList(5); + list.push('1'); + list.push('2'); + list.push('3'); + list.push('4'); + list.push('5'); + list.trimStart(1); + assert.equal(list.length, 4); + assert.deepEqual(list.get(0), '2'); + assert.deepEqual(list.get(1), '3'); + assert.deepEqual(list.get(2), '4'); + assert.deepEqual(list.get(3), '5'); + list.trimStart(2); + assert.equal(list.length, 2); + assert.deepEqual(list.get(0), '4'); + assert.deepEqual(list.get(1), '5'); + }); + + it('should remove all items if the requested trim amount is larger than the list\'s length', () => { + const list = new CircularList(5); + list.push('1'); + list.trimStart(2); + assert.equal(list.length, 0); + }); + }); + + describe('shiftElements', () => { + it('should not mutate the list when count is 0', () => { + const list = new CircularList(5); + list.push(1); + list.push(2); + list.shiftElements(0, 0, 1); + assert.equal(list.length, 2); + assert.equal(list.get(0), 1); + assert.equal(list.get(1), 2); + }); + + it('should throw for invalid args', () => { + const list = new CircularList(5); + list.push(1); + assert.throws(() => list.shiftElements(-1, 1, 1), 'start argument out of range'); + assert.throws(() => list.shiftElements(1, 1, 1), 'start argument out of range'); + assert.throws(() => list.shiftElements(0, 1, -1), 'Cannot shift elements in list beyond index 0'); + }); + + it('should shift an element forward', () => { + const list = new CircularList(5); + list.push(1); + list.push(2); + list.shiftElements(0, 1, 1); + assert.equal(list.length, 2); + assert.equal(list.get(0), 1); + assert.equal(list.get(1), 1); + }); + + it('should shift elements forward', () => { + const list = new CircularList(5); + list.push(1); + list.push(2); + list.push(3); + list.push(4); + list.shiftElements(0, 2, 2); + assert.equal(list.length, 4); + assert.equal(list.get(0), 1); + assert.equal(list.get(1), 2); + assert.equal(list.get(2), 1); + assert.equal(list.get(3), 2); + }); + + it('should shift elements forward, expanding the list if needed', () => { + const list = new CircularList(5); + list.push(1); + list.push(2); + list.shiftElements(0, 2, 2); + assert.equal(list.length, 4); + assert.equal(list.get(0), 1); + assert.equal(list.get(1), 2); + assert.equal(list.get(2), 1); + assert.equal(list.get(3), 2); + }); + + it('should shift elements forward, wrapping the list if needed', () => { + const list = new CircularList(5); + list.push(1); + list.push(2); + list.push(3); + list.push(4); + list.push(5); + list.shiftElements(2, 2, 3); + assert.equal(list.length, 5); + assert.equal(list.get(0), 3); + assert.equal(list.get(1), 4); + assert.equal(list.get(2), 5); + assert.equal(list.get(3), 3); + assert.equal(list.get(4), 4); + }); + + it('should shift an element backwards', () => { + const list = new CircularList(5); + list.push(1); + list.push(2); + list.shiftElements(1, 1, -1); + assert.equal(list.length, 2); + assert.equal(list.get(0), 2); + assert.equal(list.get(1), 2); + }); + + it('should shift elements backwards', () => { + const list = new CircularList(5); + list.push(1); + list.push(2); + list.push(3); + list.push(4); + list.shiftElements(2, 2, -2); + assert.equal(list.length, 4); + assert.equal(list.get(0), 3); + assert.equal(list.get(1), 4); + assert.equal(list.get(2), 3); + assert.equal(list.get(3), 4); + }); + }); +}); diff --git a/src/common/services/CircularList.ts b/src/common/services/CircularList.ts new file mode 100644 index 000000000..52626161f --- /dev/null +++ b/src/common/services/CircularList.ts @@ -0,0 +1,241 @@ +/** + * Copyright (c) 2016 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ICircularList } from 'common/services/Types'; +import { Disposable } from 'common/base/Lifecycle'; +import { Emitter } from 'common/base/Event'; + +export interface IInsertEvent { + index: number; + amount: number; +} + +export interface IDeleteEvent { + index: number; + amount: number; +} + +/** + * Represents a circular list; a list with a maximum size that wraps around when push is called, + * overriding values at the start of the list. + */ +export class CircularList extends Disposable implements ICircularList { + protected _array: (T | undefined)[]; + private _startIndex: number; + private _length: number; + + public readonly onDeleteEmitter = this._register(new Emitter()); + public readonly onDelete = this.onDeleteEmitter.event; + public readonly onInsertEmitter = this._register(new Emitter()); + public readonly onInsert = this.onInsertEmitter.event; + public readonly onTrimEmitter = this._register(new Emitter()); + public readonly onTrim = this.onTrimEmitter.event; + + constructor( + private _maxLength: number + ) { + super(); + this._array = new Array(this._maxLength); + this._startIndex = 0; + this._length = 0; + } + + public get maxLength(): number { + return this._maxLength; + } + + public set maxLength(newMaxLength: number) { + // There was no change in maxLength, return early. + if (this._maxLength === newMaxLength) { + return; + } + + // Reconstruct array, starting at index 0. Only transfer values from the + // indexes 0 to length. + const newArray = new Array(newMaxLength); + for (let i = 0; i < Math.min(newMaxLength, this.length); i++) { + newArray[i] = this._array[this._getCyclicIndex(i)]; + } + this._array = newArray; + this._maxLength = newMaxLength; + this._startIndex = 0; + } + + public get length(): number { + return this._length; + } + + public set length(newLength: number) { + if (newLength > this._length) { + for (let i = this._length; i < newLength; i++) { + this._array[i] = undefined; + } + } + this._length = newLength; + } + + /** + * Gets the value at an index. + * + * Note that for performance reasons there is no bounds checking here, the index reference is + * circular so this should always return a value and never throw. + * @param index The index of the value to get. + * @returns The value corresponding to the index. + */ + public get(index: number): T | undefined { + return this._array[this._getCyclicIndex(index)]; + } + + /** + * Sets the value at an index. + * + * Note that for performance reasons there is no bounds checking here, the index reference is + * circular so this should always return a value and never throw. + * @param index The index to set. + * @param value The value to set. + */ + public set(index: number, value: T | undefined): void { + this._array[this._getCyclicIndex(index)] = value; + } + + /** + * Pushes a new value onto the list, wrapping around to the start of the array, overriding index 0 + * if the maximum length is reached. + * @param value The value to push onto the list. + */ + public push(value: T): void { + this._array[this._getCyclicIndex(this._length)] = value; + if (this._length === this._maxLength) { + this._startIndex = ++this._startIndex % this._maxLength; + this.onTrimEmitter.fire(1); + } else { + this._length++; + } + } + + /** + * Advance ringbuffer index and return current element for recycling. + * Note: The buffer must be full for this method to work. + * @throws When the buffer is not full. + */ + public recycle(): T { + if (this._length !== this._maxLength) { + throw new Error('Can only recycle when the buffer is full'); + } + this._startIndex = ++this._startIndex % this._maxLength; + this.onTrimEmitter.fire(1); + return this._array[this._getCyclicIndex(this._length - 1)]!; + } + + /** + * Ringbuffer is at max length. + */ + public get isFull(): boolean { + return this._length === this._maxLength; + } + + /** + * Removes and returns the last value on the list. + * @returns The popped value. + */ + public pop(): T | undefined { + return this._array[this._getCyclicIndex(this._length-- - 1)]; + } + + /** + * Deletes and/or inserts items at a particular index (in that order). Unlike + * Array.prototype.splice, this operation does not return the deleted items as a new array in + * order to save creating a new array. Note that this operation may shift all values in the list + * in the worst case. + * @param start The index to delete and/or insert. + * @param deleteCount The number of elements to delete. + * @param items The items to insert. + */ + public splice(start: number, deleteCount: number, ...items: T[]): void { + // Delete items + if (deleteCount) { + for (let i = start; i < this._length - deleteCount; i++) { + this._array[this._getCyclicIndex(i)] = this._array[this._getCyclicIndex(i + deleteCount)]; + } + this._length -= deleteCount; + this.onDeleteEmitter.fire({ index: start, amount: deleteCount }); + } + + // Add items + for (let i = this._length - 1; i >= start; i--) { + this._array[this._getCyclicIndex(i + items.length)] = this._array[this._getCyclicIndex(i)]; + } + for (let i = 0; i < items.length; i++) { + this._array[this._getCyclicIndex(start + i)] = items[i]; + } + if (items.length) { + this.onInsertEmitter.fire({ index: start, amount: items.length }); + } + + // Adjust length as needed + if (this._length + items.length > this._maxLength) { + const countToTrim = (this._length + items.length) - this._maxLength; + this._startIndex += countToTrim; + this._length = this._maxLength; + this.onTrimEmitter.fire(countToTrim); + } else { + this._length += items.length; + } + } + + /** + * Trims a number of items from the start of the list. + * @param count The number of items to remove. + */ + public trimStart(count: number): void { + if (count > this._length) { + count = this._length; + } + this._startIndex += count; + this._length -= count; + this.onTrimEmitter.fire(count); + } + + public shiftElements(start: number, count: number, offset: number): void { + if (count <= 0) { + return; + } + if (start < 0 || start >= this._length) { + throw new Error('start argument out of range'); + } + if (start + offset < 0) { + throw new Error('Cannot shift elements in list beyond index 0'); + } + + if (offset > 0) { + for (let i = count - 1; i >= 0; i--) { + this.set(start + i + offset, this.get(start + i)); + } + const expandListBy = (start + count + offset) - this._length; + if (expandListBy > 0) { + this._length += expandListBy; + while (this._length > this._maxLength) { + this._length--; + this._startIndex++; + this.onTrimEmitter.fire(1); + } + } + } else { + for (let i = 0; i < count; i++) { + this.set(start + i + offset, this.get(start + i)); + } + } + } + + /** + * Gets the cyclic index for the specified regular index. The cyclic index can then be used on the + * backing array to get the element associated with the regular index. + * @param index The regular index. + * @returns The cyclic index. + */ + private _getCyclicIndex(index: number): number { + return (this._startIndex + index) % this._maxLength; + } +} diff --git a/src/common/services/OptionsService.test.ts b/src/common/services/OptionsService.test.ts new file mode 100644 index 000000000..6f5e7b4b0 --- /dev/null +++ b/src/common/services/OptionsService.test.ts @@ -0,0 +1,150 @@ +/** + * Copyright (c) 2020 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { OptionsService, DEFAULT_OPTIONS } from 'common/services/OptionsService'; +import { IDisposable } from 'common/services/Types'; + +describe('OptionsService', () => { + describe('constructor', () => { + const originalError = console.error; + beforeEach(() => { + console.error = () => { }; + }); + afterEach(() => { + console.error = originalError; + }); + it('uses default value if invalid constructor option values passed for cols/rows', () => { + const optionsService = new OptionsService({ cols: undefined, rows: undefined }); + assert.equal(optionsService.options.rows, DEFAULT_OPTIONS.rows); + assert.equal(optionsService.options.cols, DEFAULT_OPTIONS.cols); + }); + it('uses values from constructor option values if correctly passed', () => { + const optionsService = new OptionsService({ cols: 80, rows: 25 }); + assert.equal(optionsService.options.rows, 25); + assert.equal(optionsService.options.cols, 80); + }); + it('uses default value if invalid constructor option value passed', () => { + assert.equal(new OptionsService({ tabStopWidth: 0 }).options.tabStopWidth, DEFAULT_OPTIONS.tabStopWidth); + }); + it('object.keys return the correct number of options', () => { + const optionsService = new OptionsService({ cols: 80, rows: 25 }); + assert.notEqual(Object.keys(optionsService.options).length, 0); + }); + }); + describe('setOption', () => { + let service: OptionsService; + beforeEach(() => { + service = new OptionsService({}); + }); + it('applies valid fontWeight option values', () => { + service.options.fontWeight = 'bold'; + assert.equal(service.options.fontWeight, 'bold', '"bold" keyword value should be applied'); + + service.options.fontWeight = 'normal'; + assert.equal(service.options.fontWeight, 'normal', '"normal" keyword value should be applied'); + + service.options.fontWeight = '600'; + assert.equal(service.options.fontWeight, '600', 'String numeric values should be applied'); + + service.options.fontWeight = 350; + assert.equal(service.options.fontWeight, 350, 'Values between 1 and 1000 should be applied as is'); + + service.options.fontWeight = 1; + assert.equal(service.options.fontWeight, 1, 'Range should include minimum value: 1'); + + service.options.fontWeight = 1000; + assert.equal(service.options.fontWeight, 1000, 'Range should include maximum value: 1000'); + }); + it('normalizes invalid fontWeight option values', () => { + service.options.fontWeight = 350; + assert.doesNotThrow(() => service.options.fontWeight = 10000, 'fontWeight should be normalized instead of throwing'); + assert.equal(service.options.fontWeight, DEFAULT_OPTIONS.fontWeight, 'Values greater than 1000 should be reset to default'); + + service.options.fontWeight = 350; + service.options.fontWeight = -10; + assert.equal(service.options.fontWeight, DEFAULT_OPTIONS.fontWeight, 'Values less than 1 should be reset to default'); + + service.options.fontWeight = 350; + service.options.fontWeight = 'bold700' as any; + assert.equal(service.options.fontWeight, DEFAULT_OPTIONS.fontWeight, 'Wrong string literals should be reset to default'); + }); + }); + describe('onOptionChange', () => { + let service: OptionsService; + beforeEach(() => { + service = new OptionsService({}); + }); + it('should fire on any option change', async () => { + let disposable: IDisposable; + await new Promise(r => { + disposable = service.onOptionChange(e => { + assert.strictEqual(e, 'cursorWidth'); + r(); + }); + service.options.cursorWidth = 10; + }); + disposable!.dispose(); + await new Promise(r => { + service.onOptionChange(e => { + assert.strictEqual(e, 'scrollback'); + r(); + }); + service.options.scrollback = 20; + }); + }); + }); + describe('onSpecificOptionChange', () => { + let service: OptionsService; + beforeEach(() => { + service = new OptionsService({}); + }); + it('should fire only on a specific option change', async () => { + await new Promise(r => { + service.onSpecificOptionChange('scrollback', e => { + assert.strictEqual(e, 20); + r(); + }); + service.options.cursorWidth = 10; + service.options.scrollback = 20; + }); + }); + }); + describe('onSpecificOptionChange', () => { + let service: OptionsService; + beforeEach(() => { + service = new OptionsService({}); + }); + it('should fire only on a specific option change', async () => { + await new Promise(r => { + service.onSpecificOptionChange('scrollback', e => { + assert.strictEqual(e, 20); + r(); + }); + service.options.cursorWidth = 10; + service.options.scrollback = 20; + }); + }); + }); + describe('onMultipleOptionChange', () => { + let service: OptionsService; + beforeEach(() => { + service = new OptionsService({}); + }); + it('should fire only for specific options', async () => { + await new Promise(r => { + let called = false; + service.onMultipleOptionChange(['scrollback'], () => { + called = true; + }); + service.options.cursorWidth = 10; + assert.notOk(called); + service.options.scrollback = 20; + assert.ok(called); + r(); + }); + }); + }); +}); diff --git a/src/common/services/OptionsService.ts b/src/common/services/OptionsService.ts new file mode 100644 index 000000000..c2dc22978 --- /dev/null +++ b/src/common/services/OptionsService.ts @@ -0,0 +1,219 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { Disposable, toDisposable } from 'common/base/Lifecycle'; +import { isMac } from 'common/base/Platform'; +import { CursorStyle, IDisposable } from 'common/services/Types'; +import { FontWeight, IOptionsService, ITerminalOptions } from 'common/services/Services'; +import { Emitter } from 'common/base/Event'; + +export const DEFAULT_OPTIONS: Readonly> = { + cols: 80, + rows: 24, + showCursorImmediately: false, + cursorBlink: false, + blinkIntervalDuration: 0, + cursorStyle: 'block', + cursorWidth: 1, + cursorInactiveStyle: 'outline', + drawBoldTextInBrightColors: true, + documentOverride: null, + fastScrollSensitivity: 5, + fontFamily: 'monospace', + fontSize: 15, + fontWeight: 'normal', + fontWeightBold: 'bold', + ignoreBracketedPasteMode: false, + lineHeight: 1.0, + letterSpacing: 0, + linkHandler: null, + logLevel: 'info', + logger: null, + scrollback: 1000, + scrollbar: { showScrollbar: true }, + scrollOnEraseInDisplay: false, + scrollOnUserInput: true, + scrollSensitivity: 1, + screenReaderMode: false, + smoothScrollDuration: 0, + macOptionIsMeta: false, + macOptionClickForcesSelection: false, + minimumContrastRatio: 1, + mouseEventsRequireAlt: false, + disableStdin: false, + allowProposedApi: false, + allowTransparency: false, + tabStopWidth: 8, + theme: {}, + reflowCursorLine: false, + rescaleOverlappingGlyphs: false, + rightClickSelectsWord: isMac, + windowOptions: {}, + windowsPty: {}, + wordSeparator: ' ()[]{}\',"`', + altClickMovesCursor: true, + convertEol: false, + termName: 'xterm', + quirks: {}, + vtExtensions: {} +}; + +const FONT_WEIGHT_OPTIONS: Extract[] = ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900']; + +export class OptionsService extends Disposable implements IOptionsService { + public serviceBrand: any; + + public readonly rawOptions: Required; + public options: Required; + + private readonly _onOptionChange = this._register(new Emitter()); + public readonly onOptionChange = this._onOptionChange.event; + + constructor(options: Partial) { + super(); + // set the default value of each option + const defaultOptions = { ...DEFAULT_OPTIONS }; + for (const key in options) { + if (key in defaultOptions) { + try { + const newValue = options[key]; + defaultOptions[key] = this._sanitizeAndValidateOption(key, newValue); + } catch (e) { + console.error(e); + } + } + } + + // set up getters and setters for each option + this.rawOptions = defaultOptions; + this.options = { ... defaultOptions }; + this._setupOptions(); + + // Clear out options that could link outside xterm.js as they could easily cause an embedder + // memory leak + this._register(toDisposable(() => { + this.rawOptions.linkHandler = null; + this.rawOptions.documentOverride = null; + })); + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + public onSpecificOptionChange(key: T, listener: (value: ITerminalOptions[T]) => any): IDisposable { + return this.onOptionChange(eventKey => { + if (eventKey === key) { + listener(this.rawOptions[key]); + } + }); + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + public onMultipleOptionChange(keys: (keyof ITerminalOptions)[], listener: () => any): IDisposable { + return this.onOptionChange(eventKey => { + if (keys.indexOf(eventKey) !== -1) { + listener(); + } + }); + } + + private _setupOptions(): void { + const getter = (propName: string): any => { + if (!(propName in DEFAULT_OPTIONS)) { + throw new Error(`No option with key "${propName}"`); + } + return this.rawOptions[propName]; + }; + + const setter = (propName: string, value: any): void => { + if (!(propName in DEFAULT_OPTIONS)) { + throw new Error(`No option with key "${propName}"`); + } + + value = this._sanitizeAndValidateOption(propName, value); + // Don't fire an option change event if they didn't change + if (this.rawOptions[propName] !== value) { + this.rawOptions[propName] = value; + this._onOptionChange.fire(propName); + } + }; + + for (const propName in this.rawOptions) { + const desc = { + get: getter.bind(this, propName), + set: setter.bind(this, propName) + }; + Object.defineProperty(this.options, propName, desc); + } + } + + private _sanitizeAndValidateOption(key: string, value: any): any { + switch (key) { + case 'cursorStyle': + if (!value) { + value = DEFAULT_OPTIONS[key]; + } + if (!isCursorStyle(value)) { + throw new Error(`"${value}" is not a valid value for ${key}`); + } + break; + case 'wordSeparator': + if (!value) { + value = DEFAULT_OPTIONS[key]; + } + break; + case 'fontWeight': + case 'fontWeightBold': + if (typeof value === 'number' && 1 <= value && value <= 1000) { + // already valid numeric value + break; + } + value = FONT_WEIGHT_OPTIONS.includes(value) ? value : DEFAULT_OPTIONS[key]; + break; + case 'blinkIntervalDuration': + value = Math.floor(value); + if (value < 0) { + throw new Error(`${key} cannot be less than 0, value: ${value}`); + } + break; + case 'cursorWidth': + value = Math.floor(value); + // Fall through for bounds check + case 'lineHeight': + case 'tabStopWidth': + if (value < 1) { + throw new Error(`${key} cannot be less than 1, value: ${value}`); + } + break; + case 'minimumContrastRatio': + value = Math.max(1, Math.min(21, Math.round(value * 10) / 10)); + break; + case 'scrollback': + value = Math.min(value, 4294967295); + if (value < 0) { + throw new Error(`${key} cannot be less than 0, value: ${value}`); + } + break; + case 'fastScrollSensitivity': + case 'scrollSensitivity': + if (value <= 0) { + throw new Error(`${key} cannot be less than or equal to 0, value: ${value}`); + } + break; + case 'rows': + case 'cols': + if (!value && value !== 0) { + throw new Error(`${key} must be numeric, value: ${value}`); + } + break; + case 'windowsPty': + value = value ?? {}; + break; + } + return value; + } +} + +function isCursorStyle(value: unknown): value is CursorStyle { + return value === 'block' || value === 'underline' || value === 'bar'; +} diff --git a/src/common/services/ServiceRegistry.ts b/src/common/services/ServiceRegistry.ts new file mode 100644 index 000000000..af51d1641 --- /dev/null +++ b/src/common/services/ServiceRegistry.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + * + * This was heavily inspired from microsoft/vscode's dependency injection system (MIT). + */ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IServiceIdentifier } from 'common/services/Services'; + +const enum Constants { + DI_TARGET = 'di$target', + DI_DEPENDENCIES = 'di$dependencies' +} + +export const serviceRegistry: Map> = new Map(); + +export function getServiceDependencies(ctor: any): { id: IServiceIdentifier, index: number, optional: boolean }[] { + return ctor[Constants.DI_DEPENDENCIES] || []; +} + +export function createDecorator(id: string): IServiceIdentifier { + if (serviceRegistry.has(id)) { + return serviceRegistry.get(id)!; + } + + const decorator: any = function (target: Function, key: string, index: number): any { + if (arguments.length !== 3) { + throw new Error('@IServiceName-decorator can only be used to decorate a parameter'); + } + + storeServiceDependency(decorator, target, index); + }; + + decorator._id = id; + + serviceRegistry.set(id, decorator); + return decorator; +} + +function storeServiceDependency(id: Function, target: Function, index: number): void { + if ((target as any)[Constants.DI_TARGET] === target) { + (target as any)[Constants.DI_DEPENDENCIES].push({ id, index }); + } else { + (target as any)[Constants.DI_DEPENDENCIES] = [{ id, index }]; + (target as any)[Constants.DI_TARGET] = target; + } +} diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts new file mode 100644 index 000000000..64f2529fc --- /dev/null +++ b/src/common/services/Services.ts @@ -0,0 +1,402 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import type { IDecoration, IDecorationOptions, ILinkHandler, ILogger, IWindowsPty, IOverviewRulerOptions } from '@xterm/xterm'; +import { CoreMouseEncoding, CoreMouseEventType, CursorInactiveStyle, CursorStyle, IAttributeData, ICharset, IColor, ICoreMouseEvent, ICoreMouseProtocol, IDecPrivateModes, IDisposable, IKittyKeyboardState, IModes, IOscLinkData, IWindowOptions } from 'common/services/Types'; +import { IBuffer, IBufferSet } from 'common/buffer/Types'; +import { createDecorator } from 'common/services/ServiceRegistry'; +import type { Emitter, IEvent } from 'common/base/Event'; + +export const IBufferService = createDecorator('BufferService'); +export interface IBufferService { + serviceBrand: undefined; + + readonly cols: number; + readonly rows: number; + readonly buffer: IBuffer; + readonly buffers: IBufferSet; + isUserScrolling: boolean; + onResize: IEvent; + onScroll: IEvent; + scroll(eraseAttr: IAttributeData, isWrapped?: boolean): void; + scrollLines(disp: number, suppressScrollEvent?: boolean): void; + resize(cols: number, rows: number): void; + reset(): void; +} + +export interface IBufferResizeEvent { + cols: number; + rows: number; + colsChanged: boolean; + rowsChanged: boolean; +} + +export const IMouseStateService = createDecorator('MouseStateService'); +export interface IMouseStateService { + serviceBrand: undefined; + + activeProtocol: string; + activeEncoding: string; + areMouseEventsActive: boolean; + addProtocol(name: string, protocol: ICoreMouseProtocol): void; + addEncoding(name: string, encoding: CoreMouseEncoding): void; + reset(): void; + setCustomWheelEventHandler(customWheelEventHandler: ((event: WheelEvent) => boolean) | undefined): void; + allowCustomWheelEvent(ev: WheelEvent): boolean; + + /** + * Event to announce changes in mouse tracking. + */ + onProtocolChange: IEvent; + restrictMouseEvent(event: ICoreMouseEvent): boolean; + encodeMouseEvent(event: ICoreMouseEvent): string; + readonly isDefaultEncoding: boolean; + readonly isPixelEncoding: boolean; +} + +export const ICoreService = createDecorator('CoreService'); +export interface ICoreService { + serviceBrand: undefined; + + /** + * Initially the cursor will not be visible until the first time the terminal + * is focused. + */ + isCursorInitialized: boolean; + isCursorHidden: boolean; + + readonly modes: IModes; + readonly decPrivateModes: IDecPrivateModes; + readonly kittyKeyboard: IKittyKeyboardState; + + readonly onData: IEvent; + readonly onUserInput: IEvent; + readonly onBinary: IEvent; + readonly onRequestScrollToBottom: IEvent; + + reset(): void; + + /** + * Triggers the onData event in the public API. + * @param data The data that is being emitted. + * @param wasUserInput Whether the data originated from the user (as opposed to + * resulting from parsing incoming data). When true this will also: + * - Scroll to the bottom of the buffer if option scrollOnUserInput is true. + * - Fire the `onUserInput` event (so selection can be cleared). + */ + triggerDataEvent(data: string, wasUserInput?: boolean): void; + + /** + * Triggers the onBinary event in the public API. + * @param data The data that is being emitted. + */ + triggerBinaryEvent(data: string): void; +} + +export const ICharsetService = createDecorator('CharsetService'); +export interface ICharsetService { + serviceBrand: undefined; + + charset: ICharset | undefined; + readonly glevel: number; + readonly charsets: (ICharset | undefined)[]; + + reset(): void; + + /** + * Set the G level of the terminal. + * @param g + */ + setgLevel(g: number): void; + + /** + * Set the charset for the given G level of the terminal. + * @param g + * @param charset + */ + setgCharset(g: number, charset: ICharset | undefined): void; +} + +export interface IServiceIdentifier { + (...args: any[]): void; + type: T; + _id: string; +} + +export interface IBrandedService { + serviceBrand: undefined; +} + +type GetLeadingNonServiceArgs = TArgs extends [] ? [] + : TArgs extends [...infer TFirst, infer TLast] ? TLast extends IBrandedService ? GetLeadingNonServiceArgs : TArgs + : never; + +export const IInstantiationService = createDecorator('InstantiationService'); +export interface IInstantiationService { + serviceBrand: undefined; + + setService(id: IServiceIdentifier, instance: T): void; + getService(id: IServiceIdentifier): T | undefined; + createInstance any, R extends InstanceType>(t: Ctor, ...args: GetLeadingNonServiceArgs>): R; +} + +export enum LogLevelEnum { + TRACE = 0, + DEBUG = 1, + INFO = 2, + WARN = 3, + ERROR = 4, + OFF = 5 +} + +export const ILogService = createDecorator('LogService'); +export interface ILogService { + serviceBrand: undefined; + + readonly logLevel: LogLevelEnum; + + trace(message: any, ...optionalParams: any[]): void; + debug(message: any, ...optionalParams: any[]): void; + info(message: any, ...optionalParams: any[]): void; + warn(message: any, ...optionalParams: any[]): void; + error(message: any, ...optionalParams: any[]): void; +} + +export const IOptionsService = createDecorator('OptionsService'); +export interface IOptionsService { + serviceBrand: undefined; + + /** + * Read only access to the raw options object, this is an internal-only fast path for accessing + * single options without any validation as we trust TypeScript to enforce correct usage + * internally. + */ + readonly rawOptions: Required; + + /** + * Options as exposed through the public API, this property uses getters and setters with + * validation which makes it safer but slower. {@link rawOptions} should be used for pretty much + * all internal usage for performance reasons. + */ + readonly options: Required; + + /** + * Adds an event listener for when any option changes. + */ + readonly onOptionChange: IEvent; + + /** + * Adds an event listener for when a specific option changes, this is a convenience method that is + * preferred over {@link onOptionChange} when only a single option is being listened to. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + onSpecificOptionChange(key: T, listener: (arg1: Required[T]) => any): IDisposable; + + /** + * Adds an event listener for when a set of specific options change, this is a convenience method + * that is preferred over {@link onOptionChange} when multiple options are being listened to and + * handled the same way. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + onMultipleOptionChange(keys: (keyof ITerminalOptions)[], listener: () => any): IDisposable; +} + +export type FontWeight = 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900' | number; +export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'off'; + +export interface ITerminalOptions { + allowProposedApi?: boolean; + allowTransparency?: boolean; + altClickMovesCursor?: boolean; + cols?: number; + convertEol?: boolean; + cursorBlink?: boolean; + blinkIntervalDuration?: number; + cursorStyle?: CursorStyle; + cursorWidth?: number; + cursorInactiveStyle?: CursorInactiveStyle; + disableStdin?: boolean; + documentOverride?: any | null; + drawBoldTextInBrightColors?: boolean; + fastScrollSensitivity?: number; + fontSize?: number; + fontFamily?: string; + fontWeight?: FontWeight; + fontWeightBold?: FontWeight; + ignoreBracketedPasteMode?: boolean; + letterSpacing?: number; + lineHeight?: number; + linkHandler?: ILinkHandler | null; + logLevel?: LogLevel; + logger?: ILogger | null; + macOptionIsMeta?: boolean; + macOptionClickForcesSelection?: boolean; + minimumContrastRatio?: number; + mouseEventsRequireAlt?: boolean; + reflowCursorLine?: boolean; + rescaleOverlappingGlyphs?: boolean; + rightClickSelectsWord?: boolean; + rows?: number; + showCursorImmediately?: boolean; + screenReaderMode?: boolean; + scrollback?: number; + scrollOnUserInput?: boolean; + scrollSensitivity?: number; + smoothScrollDuration?: number; + tabStopWidth?: number; + theme?: ITheme; + windowsPty?: IWindowsPty; + windowOptions?: IWindowOptions; + wordSeparator?: string; + quirks?: ITerminalQuirks; + scrollbar?: IScrollbarOptions; + scrollOnEraseInDisplay?: boolean; + vtExtensions?: IVtExtensions; + + [key: string]: any; + termName: string; +} + +export interface ITheme { + foreground?: string; + background?: string; + cursor?: string; + cursorAccent?: string; + selectionForeground?: string; + selectionBackground?: string; + selectionInactiveBackground?: string; + scrollbarSliderBackground?: string; + scrollbarSliderHoverBackground?: string; + scrollbarSliderActiveBackground?: string; + overviewRulerBorder?: string; + black?: string; + red?: string; + green?: string; + yellow?: string; + blue?: string; + magenta?: string; + cyan?: string; + white?: string; + brightBlack?: string; + brightRed?: string; + brightGreen?: string; + brightYellow?: string; + brightBlue?: string; + brightMagenta?: string; + brightCyan?: string; + brightWhite?: string; + extendedAnsi?: string[]; +} + +export interface ITerminalQuirks { + allowSetCursorBlink?: boolean; +} + +export interface IScrollbarOptions { + showScrollbar?: boolean; + showArrows?: boolean; + width?: number; + overviewRuler?: IOverviewRulerOptions; +} + +export interface IVtExtensions { + kittyKeyboard?: boolean; + kittySgrBoldFaintControl?: boolean; + win32InputMode?: boolean; + colorSchemeQuery?: boolean; +} + +export const IOscLinkService = createDecorator('OscLinkService'); +export interface IOscLinkService { + serviceBrand: undefined; + /** + * Registers a link to the service, returning the link ID. The link data is managed by this + * service and will be freed when this current cursor position is trimmed off the buffer. + */ + registerLink(linkData: IOscLinkData): number; + /** + * Adds a line to a link if needed. + */ + addLineToLink(linkId: number, y: number): void; + /** Get the link data associated with a link ID. */ + getLinkData(linkId: number): IOscLinkData | undefined; +} + +/* + * Width and Grapheme_Cluster_Break properties of a character as a bit mask. + * + * bit 0: shouldJoin - should combine with preceding character. + * bit 1..2: wcwidth - see UnicodeCharWidth. + * bit 3..31: class of character (currently only 4 bits are used). + * This is used to determined grapheme clustering - i.e. which codepoints + * are to be combined into a single compound character. + * + * Use the UnicodeService static function createPropertyValue to create a + * UnicodeCharProperties; use extractShouldJoin, extractWidth, and + * extractCharKind to extract the components. + */ +export type UnicodeCharProperties = number; + +/** + * Width in columns of a character. + * In a CJK context, "half-width" characters (such as Latin) are width 1, + * while "full-width" characters (such as Kanji) are 2 columns wide. + * Combining characters (such as accents) are width 0. + */ +export type UnicodeCharWidth = 0 | 1 | 2; + +export const IUnicodeService = createDecorator('UnicodeService'); +export interface IUnicodeService { + serviceBrand: undefined; + /** Register an Unicode version provider. */ + register(provider: IUnicodeVersionProvider): void; + /** Registered Unicode versions. */ + readonly versions: string[]; + /** Currently active version. */ + activeVersion: string; + /** Event triggered, when activate version changed. */ + readonly onChange: IEvent; + + /** + * Unicode version dependent + */ + wcwidth(codepoint: number): UnicodeCharWidth; + getStringCellWidth(s: string): number; + /** + * Return character width and type for grapheme clustering. + * If preceding != 0, it is the return code from the previous character; + * in that case the result specifies if the characters should be joined. + */ + charProperties(codepoint: number, preceding: UnicodeCharProperties): UnicodeCharProperties; +} + +export interface IUnicodeVersionProvider { + readonly version: string; + wcwidth(ucs: number): UnicodeCharWidth; + charProperties(codepoint: number, preceding: UnicodeCharProperties): UnicodeCharProperties; +} + +export const IDecorationService = createDecorator('DecorationService'); +export interface IDecorationService extends IDisposable { + serviceBrand: undefined; + readonly decorations: IterableIterator; + readonly onDecorationRegistered: IEvent; + readonly onDecorationRemoved: IEvent; + registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined; + reset(): void; + /** + * Trigger a callback over the decoration at a cell (in no particular order). This uses a callback + * instead of an iterator as it's typically used in hot code paths. + */ + forEachDecorationAtCell(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void): void; +} +export interface IInternalDecoration extends IDecoration { + readonly options: IDecorationOptions; + readonly backgroundColorRGB: IColor | undefined; + readonly foregroundColorRGB: IColor | undefined; + readonly onRenderEmitter: Emitter; + /** @internal Start line for line-index removal; kept in sync on buffer line shifts. */ + _indexedStartLine: number; +} diff --git a/src/common/services/TaskQueue.ts b/src/common/services/TaskQueue.ts new file mode 100644 index 000000000..77f9aea55 --- /dev/null +++ b/src/common/services/TaskQueue.ts @@ -0,0 +1,175 @@ +/** + * Copyright (c) 2022 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import type { ILogService } from 'common/services/Services'; + +interface ITaskQueue { + /** + * Adds a task to the queue which will run in a future idle callback. + * To avoid perceivable stalls on the mainthread, tasks with heavy workload + * should split their work into smaller pieces and return `true` to get + * called again until the work is done (on falsy return value). + */ + enqueue(task: () => boolean | void): void; + + /** + * Flushes the queue, running all remaining tasks synchronously. + */ + flush(): void; + + /** + * Clears any remaining tasks from the queue, these will not be run. + */ + clear(): void; +} + +interface ITaskDeadline { + timeRemaining(): number; +} +type CallbackWithDeadline = (deadline: ITaskDeadline) => void; + +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; + + public enqueue(task: () => boolean | void): void { + this._tasks.push(task); + this._start(); + } + + public flush(): void { + while (this._i < this._tasks.length) { + if (!this._tasks[this._i]()) { + this._i++; + } + } + this.clear(); + } + + public clear(): void { + if (this._idleCallback) { + this._cancelCallback(this._idleCallback); + this._idleCallback = undefined; + } + this._i = 0; + this._tasks.length = 0; + } + + private _start(): void { + if (!this._idleCallback) { + this._idleCallback = this._requestCallback(this._process.bind(this)); + } + } + + private _process(deadline: ITaskDeadline): void { + this._idleCallback = undefined; + let taskDuration: number; + let longestTask = 0; + let lastDeadlineRemaining = deadline.timeRemaining(); + let deadlineRemaining: number; + while (this._i < this._tasks.length) { + taskDuration = performance.now(); + if (!this._tasks[this._i]()) { + this._i++; + } + // other than performance.now, performance.now might not be stable (changes on wall clock + // changes), this is not an issue here as a clock change during a short running task is very + // unlikely in case it still happened and leads to negative duration, simply assume 1 msec + taskDuration = Math.max(1, performance.now() - taskDuration); + longestTask = Math.max(taskDuration, longestTask); + // Guess the following task will take a similar time to the longest task in this batch, allow + // additional room to try avoid exceeding the deadline + deadlineRemaining = deadline.timeRemaining(); + if (longestTask * 1.5 > deadlineRemaining) { + // 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) { + this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(lastDeadlineRemaining - taskDuration))}ms`); + } + this._start(); + return; + } + lastDeadlineRemaining = deadlineRemaining; + } + this.clear(); + } +} + +/** + * A queue of that runs tasks over several tasks via setTimeout, trying to maintain above 60 frames + * per second. The tasks will run in the order they are enqueued, but they will run some time later, + * and care should be taken to ensure they're non-urgent and will not introduce race conditions. + */ +export class PriorityTaskQueue extends TaskQueue { + protected _requestCallback(callback: CallbackWithDeadline): number { + return setTimeout(() => callback(this._createDeadline(16))); + } + + protected _cancelCallback(identifier: number): void { + clearTimeout(identifier); + } + + private _createDeadline(duration: number): ITaskDeadline { + const end = performance.now() + duration; + return { + timeRemaining: () => Math.max(0, end - performance.now()) + }; + } +} + +class IdleTaskQueueInternal extends TaskQueue { + protected _requestCallback(callback: IdleRequestCallback): number { + return requestIdleCallback(callback); + } + + protected _cancelCallback(identifier: number): void { + cancelIdleCallback(identifier); + } +} + +/** + * A queue of that runs tasks over several idle callbacks, trying to respect the idle callback's + * deadline given by the environment. The tasks will run in the order they are enqueued, but they + * will run some time later, and care should be taken to ensure they're non-urgent and will not + * introduce race conditions. + * + * This reverts to a {@link PriorityTaskQueue} if the environment does not support idle callbacks. + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +export const IdleTaskQueue = ('requestIdleCallback' in globalThis) ? IdleTaskQueueInternal : PriorityTaskQueue; + +/** + * An object that tracks a single debounced task that will run on the next idle frame. When called + * multiple times, only the last set task will run. + */ +export class DebouncedIdleTask { + private _queue: ITaskQueue; + + constructor(logService: ILogService) { + this._queue = new IdleTaskQueue(logService); + } + + public set(task: () => boolean | void): void { + this._queue.clear(); + this._queue.enqueue(task); + } + + public flush(): void { + this._queue.flush(); + } + + public dispose(): void { + this._queue.clear(); + } +} diff --git a/src/common/services/Types.ts b/src/common/services/Types.ts new file mode 100644 index 000000000..44946c9ef --- /dev/null +++ b/src/common/services/Types.ts @@ -0,0 +1,570 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import type { IDeleteEvent, IInsertEvent } from 'common/services/CircularList'; +import type { UnderlineStyle } from 'common/buffer/Constants'; +import type { IBufferSet } from 'common/buffer/Types'; +import type { IParams } from 'common/parser/Types'; +import type { IMouseStateService, ICoreService, IOptionsService, IUnicodeService } from 'common/services/Services'; +import { IFunctionIdentifier, ITerminalOptions as IPublicTerminalOptions } from '@xterm/xterm'; +import type { Emitter, IEvent } from 'common/base/Event'; + +export type { ICharset } from 'common/data/Charsets'; + +export interface ICoreTerminal { + mouseStateService: IMouseStateService; + coreService: ICoreService; + optionsService: IOptionsService; + unicodeService: IUnicodeService; + buffers: IBufferSet; + options: Required; + registerCsiHandler(id: IFunctionIdentifier, callback: (params: IParams) => boolean | Promise): IDisposable; + registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: IParams) => boolean | Promise): IDisposable; + registerEscHandler(id: IFunctionIdentifier, callback: () => boolean | Promise): IDisposable; + registerOscHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable; + registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise): IDisposable; +} + +export interface IDisposable { + dispose(): void; +} + +// TODO: The options that are not in the public API should be reviewed +export interface ITerminalOptions extends IPublicTerminalOptions { + [key: string]: any; + convertEol?: boolean; + termName?: string; +} + +export type CursorStyle = 'block' | 'underline' | 'bar'; + +export type CursorInactiveStyle = 'outline' | 'block' | 'bar' | 'underline' | 'none'; + +export type XtermListener = (...args: any[]) => void; + +/** + * A keyboard event interface which does not depend on the DOM, KeyboardEvent implicitly extends + * this event. + */ +export interface IKeyboardEvent { + altKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + metaKey: boolean; + /** @deprecated See KeyboardEvent.keyCode */ + keyCode: number; + key: string; + type: string; + code: string; +} + +export interface IScrollEvent { + position: number; +} + +export interface ICircularList { + length: number; + maxLength: number; + isFull: boolean; + + onDeleteEmitter: Emitter; + onDelete: IEvent; + onInsertEmitter: Emitter; + onInsert: IEvent; + onTrimEmitter: Emitter; + onTrim: IEvent; + + get(index: number): T | undefined; + set(index: number, value: T): void; + push(value: T): void; + recycle(): T; + pop(): T | undefined; + splice(start: number, deleteCount: number, ...items: T[]): void; + trimStart(count: number): void; + shiftElements(start: number, count: number, offset: number): void; +} + +export const enum KeyboardResultType { + SEND_KEY, + SELECT_ALL, + PAGE_UP, + PAGE_DOWN +} + +export interface IKeyboardResult { + type: KeyboardResultType; + cancel: boolean; + key: string | undefined; +} + +export type CharData = [attr: number, char: string, width: number, code: number]; + +export interface IColor { + readonly css: string; + readonly rgba: number; // 32-bit int with rgba in each byte +} +export type IColorRGB = [red: number, green: number, blue: number]; + +export interface IExtendedAttrs { + ext: number; + underlineStyle: UnderlineStyle; + underlineColor: number; + underlineVariantOffset: number; + urlId: number; + clone(): IExtendedAttrs; + isEmpty(): boolean; +} + +/** + * Tracks the current hyperlink. Since these are treated as extended attirbutes, these get passed on + * to the linkifier when anything is printed. Doing it this way ensures that even when the cursor + * moves around unexpectedly the link is tracked, as opposed to using a start position and + * finalizing it at the end. + */ +export interface IOscLinkData { + id?: string; + uri: string; +} + +/** + * An object that represents all attributes of a cell. + */ +export interface IAttributeData { + /** + * "fg" is a 32-bit unsigned integer that stores the foreground color of the cell in the 24 least + * significant bits and additional flags in the remaining 8 bits. + */ + fg: number; + /** + * "bg" is a 32-bit unsigned integer that stores the background color of the cell in the 24 least + * significant bits and additional flags in the remaining 8 bits. + */ + bg: number; + /** + * "extended", aka "ext", stores extended attributes beyond those available in fg and bg. This + * data is optional on a cell and encodes less common data. + */ + extended: IExtendedAttrs; + + clone(): IAttributeData; + + // flags + isInverse(): number; + isBold(): number; + isUnderline(): number; + isBlink(): number; + isInvisible(): number; + isItalic(): number; + isDim(): number; + isStrikethrough(): number; + isProtected(): number; + isOverline(): number; + + /** + * The color mode of the foreground color which determines how to decode {@link getFgColor}, + * possible values include {@link Attributes.CM_DEFAULT}, {@link Attributes.CM_P16}, + * {@link Attributes.CM_P256} and {@link Attributes.CM_RGB}. + */ + getFgColorMode(): number; + /** + * The color mode of the background color which determines how to decode {@link getBgColor}, + * possible values include {@link Attributes.CM_DEFAULT}, {@link Attributes.CM_P16}, + * {@link Attributes.CM_P256} and {@link Attributes.CM_RGB}. + */ + getBgColorMode(): number; + isFgRGB(): boolean; + isBgRGB(): boolean; + isFgPalette(): boolean; + isBgPalette(): boolean; + isFgDefault(): boolean; + isBgDefault(): boolean; + isAttributeDefault(): boolean; + + /** + * Gets an integer representation of the foreground color, how to decode the color depends on the + * color mode {@link getFgColorMode}. + */ + getFgColor(): number; + /** + * Gets an integer representation of the background color, how to decode the color depends on the + * color mode {@link getBgColorMode}. + */ + getBgColor(): number; + + // extended attrs + hasExtendedAttrs(): number; + updateExtended(): void; + getUnderlineColor(): number; + getUnderlineColorMode(): number; + isUnderlineColorRGB(): boolean; + isUnderlineColorPalette(): boolean; + isUnderlineColorDefault(): boolean; + getUnderlineStyle(): number; + getUnderlineVariantOffset(): number; +} + +/** Cell data */ +export interface ICellData extends IAttributeData { + content: number; + combinedData: string; + isCombined(): number; + getWidth(): number; + getChars(): string; + getCode(): number; + setFromCharData(value: CharData): void; + getAsCharData(): CharData; +} + +/** + * Interface for a line in the terminal buffer. + */ +export interface IBufferLine { + length: number; + isWrapped: boolean; + get(index: number): CharData; + set(index: number, value: CharData): void; + loadCell(index: number, cell: ICellData): ICellData; + setCell(index: number, cell: ICellData): void; + setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void; + addCodepointToCell(index: number, codePoint: number, width: number): void; + insertCells(pos: number, n: number, ch: ICellData): void; + deleteCells(pos: number, n: number, fill: ICellData): void; + replaceCells(start: number, end: number, fill: ICellData, respectProtect?: boolean): void; + resize(cols: number, fill: ICellData): boolean; + cleanupMemory(): number; + fill(fillCellData: ICellData, respectProtect?: boolean): void; + copyFrom(line: IBufferLine): void; + clone(): IBufferLine; + getTrimmedLength(): number; + getNoBgTrimmedLength(): number; + translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string; + + /* direct access to cell attrs */ + getWidth(index: number): number; + hasWidth(index: number): number; + getFg(index: number): number; + getBg(index: number): number; + hasContent(index: number): number; + getCodePoint(index: number): number; + isCombined(index: number): number; + getString(index: number): string; +} + +export interface IMarker extends IDisposable { + readonly id: number; + readonly isDisposed: boolean; + readonly line: number; + onDispose: IEvent; +} +export interface IModes { + insertMode: boolean; +} + +export interface IDecPrivateModes { + applicationCursorKeys: boolean; + applicationKeypad: boolean; + bracketedPasteMode: boolean; + colorSchemeUpdates: boolean; + cursorBlink: boolean | undefined; + cursorStyle: CursorStyle | undefined; + origin: boolean; + reverseWraparound: boolean; + sendFocus: boolean; + synchronizedOutput: boolean; + win32InputMode: boolean; + wraparound: boolean; // defaults: xterm - true, vt100 - false +} + +/** + * Kitty keyboard protocol state. + * Maintains per-screen stacks of enhancement flags. + */ +export interface IKittyKeyboardState { + /** Current active enhancement flags (for current screen) */ + flags: number; + /** Saved flags for main screen when alt is active */ + mainFlags: number; + /** Saved flags for alternate screen when main is active */ + altFlags: number; + /** Stack of flags for main screen */ + mainStack: number[]; + /** Stack of flags for alternate screen */ + altStack: number[]; +} + +export interface IRowRange { + start: number; + end: number; +} + +/** + * Interface for mouse events in the core. + */ +export const enum CoreMouseButton { + LEFT = 0, + MIDDLE = 1, + RIGHT = 2, + NONE = 3, + WHEEL = 4, + // additional buttons 1..8 + // untested! + AUX1 = 8, + AUX2 = 9, + AUX3 = 10, + AUX4 = 11, + AUX5 = 12, + AUX6 = 13, + AUX7 = 14, + AUX8 = 15 +} + +export const enum CoreMouseAction { + UP = 0, // buttons, wheel + DOWN = 1, // buttons, wheel + LEFT = 2, // wheel only + RIGHT = 3, // wheel only + MOVE = 32 // buttons only +} + +export interface ICoreMouseEvent { + /** column (zero based). */ + col: number; + /** row (zero based). */ + row: number; + /** xy pixel positions. */ + x: number; + y: number; + /** + * Button the action occured. Due to restrictions of the tracking protocols + * it is not possible to report multiple buttons at once. + * Wheel is treated as a button. + * There are invalid combinations of buttons and actions possible + * (like move + wheel), those are silently ignored by the MouseStateService. + */ + button: CoreMouseButton; + action: CoreMouseAction; + /** + * Modifier states. + * Protocols will add/ignore those based on specific restrictions. + */ + ctrl?: boolean; + alt?: boolean; + shift?: boolean; +} + +/** + * CoreMouseEventType + * To be reported to the browser component which events a mouse + * protocol wants to be catched and forwarded as an ICoreMouseEvent + * to MouseStateService. + */ +export const enum CoreMouseEventType { + NONE = 0, + /** any mousedown event */ + DOWN = 1, + /** any mouseup event */ + UP = 2, + /** any mousemove event while a button is held */ + DRAG = 4, + /** any mousemove event without a button */ + MOVE = 8, + /** any wheel event */ + WHEEL = 16 +} + +/** + * Mouse protocol interface. + * A mouse protocol can be registered and activated at the MouseStateService. + * `events` should contain a list of needed events as a hint for the browser component + * to install/remove the appropriate event handlers. + * `restrict` applies further protocol specific restrictions like not allowed + * modifiers or filtering invalid event types. + */ +export interface ICoreMouseProtocol { + events: CoreMouseEventType; + restrict: (e: ICoreMouseEvent) => boolean; +} + +/** + * CoreMouseEncoding + * The tracking encoding can be registered and activated at the MouseStateService. + * If a ICoreMouseEvent passes all procotol restrictions it will be encoded + * with the active encoding and sent out. + * Note: Returning an empty string will supress sending a mouse report, + * which can be used to skip creating falsey reports in limited encodings + * (DEFAULT only supports up to 223 1-based as coord value). + */ +export type CoreMouseEncoding = (event: ICoreMouseEvent) => string; + +/** + * windowOptions + */ +export interface IWindowOptions { + restoreWin?: boolean; + minimizeWin?: boolean; + setWinPosition?: boolean; + setWinSizePixels?: boolean; + raiseWin?: boolean; + lowerWin?: boolean; + refreshWin?: boolean; + setWinSizeChars?: boolean; + maximizeWin?: boolean; + fullscreenWin?: boolean; + getWinState?: boolean; + getWinPosition?: boolean; + getWinSizePixels?: boolean; + getScreenSizePixels?: boolean; + getCellSizePixels?: boolean; + getWinSizeChars?: boolean; + getScreenSizeChars?: boolean; + getIconTitle?: boolean; + getWinTitle?: boolean; + pushTitle?: boolean; + popTitle?: boolean; + setWinLines?: boolean; +} + +// color events from common, used for OSC 4/10/11/12 and 104/110/111/112 +export const enum ColorRequestType { + REPORT = 0, + SET = 1, + RESTORE = 2 +} + +// IntRange from https://stackoverflow.com/a/39495173 +type Enumerate = Acc['length'] extends N + ? Acc[number] + : Enumerate; +type IntRange = Exclude, Enumerate>; + +export type ColorIndex = IntRange<0, 256>; // number from 0 to 255 +export type AllColorIndex = ColorIndex | SpecialColorIndex; +export const enum SpecialColorIndex { + FOREGROUND = 256, + BACKGROUND = 257, + CURSOR = 258 +} +export interface IColorReportRequest { + type: ColorRequestType.REPORT; + index: AllColorIndex; +} +export interface IColorSetRequest { + type: ColorRequestType.SET; + index: AllColorIndex; + color: IColorRGB; +} +export interface IColorRestoreRequest { + type: ColorRequestType.RESTORE; + index?: AllColorIndex; +} +export type IColorEvent = (IColorReportRequest | IColorSetRequest | IColorRestoreRequest)[]; + + +/** + * Calls the parser and handles actions generated by the parser. + */ +export interface IInputHandler { + onTitleChange: IEvent; + + parse(data: string | Uint8Array, promiseResult?: boolean): void | Promise; + print(data: Uint32Array, start: number, end: number): void; + registerCsiHandler(id: IFunctionIdentifier, callback: (params: IParams) => boolean | Promise): IDisposable; + registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: IParams) => boolean | Promise): IDisposable; + registerEscHandler(id: IFunctionIdentifier, callback: () => boolean | Promise): IDisposable; + registerOscHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable; + registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise): IDisposable; + + /** C0 BEL */ bell(): boolean; + /** C0 LF */ lineFeed(): boolean; + /** C0 CR */ carriageReturn(): boolean; + /** C0 BS */ backspace(): boolean; + /** C0 HT */ tab(): boolean; + /** C0 SO */ shiftOut(): boolean; + /** C0 SI */ shiftIn(): boolean; + + /** CSI @ */ insertChars(params: IParams): boolean; + /** CSI SP @ */ scrollLeft(params: IParams): boolean; + /** CSI A */ cursorUp(params: IParams): boolean; + /** CSI SP A */ scrollRight(params: IParams): boolean; + /** CSI B */ cursorDown(params: IParams): boolean; + /** CSI C */ cursorForward(params: IParams): boolean; + /** CSI D */ cursorBackward(params: IParams): boolean; + /** CSI E */ cursorNextLine(params: IParams): boolean; + /** CSI F */ cursorPrecedingLine(params: IParams): boolean; + /** CSI G */ cursorCharAbsolute(params: IParams): boolean; + /** CSI H */ cursorPosition(params: IParams): boolean; + /** CSI I */ cursorForwardTab(params: IParams): boolean; + /** CSI J */ eraseInDisplay(params: IParams): boolean; + /** CSI K */ eraseInLine(params: IParams): boolean; + /** CSI L */ insertLines(params: IParams): boolean; + /** CSI M */ deleteLines(params: IParams): boolean; + /** CSI P */ deleteChars(params: IParams): boolean; + /** CSI S */ scrollUp(params: IParams): boolean; + /** CSI T */ scrollDown(params: IParams, collect?: string): boolean; + /** CSI X */ eraseChars(params: IParams): boolean; + /** CSI Z */ cursorBackwardTab(params: IParams): boolean; + /** CSI ` */ charPosAbsolute(params: IParams): boolean; + /** CSI a */ hPositionRelative(params: IParams): boolean; + /** CSI b */ repeatPrecedingCharacter(params: IParams): boolean; + /** CSI c */ sendDeviceAttributesPrimary(params: IParams): boolean; + /** CSI > c */ sendDeviceAttributesSecondary(params: IParams): boolean; + /** CSI d */ linePosAbsolute(params: IParams): boolean; + /** CSI e */ vPositionRelative(params: IParams): boolean; + /** CSI f */ hVPosition(params: IParams): boolean; + /** CSI g */ tabClear(params: IParams): boolean; + /** CSI h */ setMode(params: IParams, collect?: string): boolean; + /** CSI l */ resetMode(params: IParams, collect?: string): boolean; + /** CSI m */ charAttributes(params: IParams): boolean; + /** CSI n */ deviceStatus(params: IParams, collect?: string): boolean; + /** CSI p */ softReset(params: IParams, collect?: string): boolean; + /** CSI q */ setCursorStyle(params: IParams, collect?: string): boolean; + /** CSI r */ setScrollRegion(params: IParams, collect?: string): boolean; + /** CSI s */ saveCursor(params: IParams): boolean; + /** CSI u */ restoreCursor(params: IParams): boolean; + /** CSI ' } */ insertColumns(params: IParams): boolean; + /** CSI ' ~ */ deleteColumns(params: IParams): boolean; + + /** OSC 0 */ + /** OSC 2 */ setTitle(data: string): boolean; + /** OSC 4 */ setOrReportIndexedColor(data: string): boolean; + /** OSC 10 */ setOrReportFgColor(data: string): boolean; + /** OSC 11 */ setOrReportBgColor(data: string): boolean; + /** OSC 12 */ setOrReportCursorColor(data: string): boolean; + /** OSC 104 */ restoreIndexedColor(data: string): boolean; + /** OSC 110 */ restoreFgColor(data: string): boolean; + /** OSC 111 */ restoreBgColor(data: string): boolean; + /** OSC 112 */ restoreCursorColor(data: string): boolean; + + /** ESC E */ nextLine(): boolean; + /** ESC = */ keypadApplicationMode(): boolean; + /** ESC > */ keypadNumericMode(): boolean; + /** ESC % G */ + /** ESC % @ */ selectDefaultCharset(): boolean; + /** ESC ( C */ + /** ESC ) C */ + /** ESC * C */ + /** ESC + C */ + /** ESC - C */ + /** ESC . C */ + /** ESC / C */ selectCharset(collectAndFlag: string): boolean; + /** ESC D */ index(): boolean; + /** ESC H */ tabSet(): boolean; + /** ESC M */ reverseIndex(): boolean; + /** ESC c */ fullReset(): boolean; + /** ESC n */ + /** ESC o */ + /** ESC | */ + /** ESC } */ + /** ESC ~ */ setgLevel(level: number): boolean; + /** ESC # 8 */ screenAlignmentPattern(): boolean; +} + +export interface IParseStack { + paused: boolean; + cursorStartX: number; + cursorStartY: number; + decodedLength: number; + position: number; +} diff --git a/src/common/services/UnicodeService.test.ts b/src/common/services/UnicodeService.test.ts new file mode 100644 index 000000000..01e3c0862 --- /dev/null +++ b/src/common/services/UnicodeService.test.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { UnicodeService } from 'common/services/UnicodeService'; +import { IUnicodeVersionProvider } from 'common/services/Services'; + +class DummyProvider implements IUnicodeVersionProvider { + public version = '123'; + public wcwidth(n: number): 0 | 1 | 2 { + return 2; + } + public charProperties(codepoint: number): number { + return UnicodeService.createPropertyValue(0, this.wcwidth(codepoint)); + } +} + +describe('unicode provider', () => { + let us: UnicodeService; + beforeEach(() => { + us = new UnicodeService(); + }); + it('default to V6', () => { + assert.equal(us.activeVersion, '6'); + assert.deepEqual(us.versions, ['6']); + assert.doesNotThrow(() => { us.activeVersion = '6'; }, `unknown Unicode version "6"`); + assert.equal(us.getStringCellWidth('hello'), 5); + }); + it('activate should throw for unknown version', () => { + assert.throws(() => { us.activeVersion = '55'; }, 'unknown Unicode version "55"'); + }); + it('should notify about version change', () => { + const notes: string[] = []; + us.onChange(version => notes.push(version)); + const dummyProvider = new DummyProvider(); + us.register(dummyProvider); + us.activeVersion = dummyProvider.version; + assert.deepEqual(notes, [dummyProvider.version]); + }); + it('correctly changes provider impl', () => { + assert.equal(us.getStringCellWidth('hello'), 5); + const dummyProvider = new DummyProvider(); + us.register(dummyProvider); + us.activeVersion = dummyProvider.version; + assert.equal(us.getStringCellWidth('hello'), 2 * 5); + }); + it('wcwidth V6 emoji test', () => { + const widthV6 = us.getStringCellWidth('🤣🤣🤣🤣🤣🤣🤣🤣🤣🤣'); + assert.equal(widthV6, 10); + }); +}); diff --git a/src/common/services/UnicodeService.ts b/src/common/services/UnicodeService.ts new file mode 100644 index 000000000..99b4546a0 --- /dev/null +++ b/src/common/services/UnicodeService.ts @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { UnicodeV6 } from 'common/input/UnicodeV6'; +import { IUnicodeService, IUnicodeVersionProvider, UnicodeCharProperties, UnicodeCharWidth } from 'common/services/Services'; +import { Emitter } from 'common/base/Event'; + +export class UnicodeService implements IUnicodeService { + public serviceBrand: any; + + private _providers: {[key: string]: IUnicodeVersionProvider} = Object.create(null); + private _active: string = ''; + private _activeProvider: IUnicodeVersionProvider; + + private readonly _onChange = new Emitter(); + public readonly onChange = this._onChange.event; + + public static extractShouldJoin(value: UnicodeCharProperties): boolean { + return (value & 1) !== 0; + } + public static extractWidth(value: UnicodeCharProperties): UnicodeCharWidth { + return ((value >> 1) & 0x3) as UnicodeCharWidth; + } + public static extractCharKind(value: UnicodeCharProperties): number { + return value >> 3; + } + public static createPropertyValue(state: number, width: number, shouldJoin: boolean = false): UnicodeCharProperties { + return ((state & 0xffffff) << 3) | ((width & 3) << 1) | (shouldJoin?1:0); + } + + constructor() { + const defaultProvider = new UnicodeV6(); + this.register(defaultProvider); + this._active = defaultProvider.version; + this._activeProvider = defaultProvider; + } + + public dispose(): void { + this._onChange.dispose(); + } + + public get versions(): string[] { + return Object.keys(this._providers); + } + + public get activeVersion(): string { + return this._active; + } + + public set activeVersion(version: string) { + if (!this._providers[version]) { + throw new Error(`unknown Unicode version "${version}"`); + } + this._active = version; + this._activeProvider = this._providers[version]; + this._onChange.fire(version); + } + + public register(provider: IUnicodeVersionProvider): void { + this._providers[provider.version] = provider; + } + + /** + * Unicode version dependent interface. + */ + public wcwidth(num: number): UnicodeCharWidth { + return this._activeProvider.wcwidth(num); + } + + public getStringCellWidth(s: string): number { + let result = 0; + let precedingInfo = 0; + const length = s.length; + for (let i = 0; i < length; ++i) { + let code = s.charCodeAt(i); + // surrogate pair first + if (0xD800 <= code && code <= 0xDBFF) { + if (++i >= length) { + // this should not happen with strings retrieved from + // Buffer.translateToString as it converts from UTF-32 + // and therefore always should contain the second part + // for any other string we still have to handle it somehow: + // simply treat the lonely surrogate first as a single char (UCS-2 behavior) + return result + this.wcwidth(code); + } + const second = s.charCodeAt(i); + // convert surrogate pair to high codepoint only for valid second part (UTF-16) + // otherwise treat them independently (UCS-2 behavior) + if (0xDC00 <= second && second <= 0xDFFF) { + code = (code - 0xD800) * 0x400 + second - 0xDC00 + 0x10000; + } else { + result += this.wcwidth(second); + } + } + const currentInfo = this.charProperties(code, precedingInfo); + let chWidth = UnicodeService.extractWidth(currentInfo); + if (UnicodeService.extractShouldJoin(currentInfo)) { + chWidth -= UnicodeService.extractWidth(precedingInfo); + } + result += chWidth; + precedingInfo = currentInfo; + } + return result; + } + + public charProperties(codepoint: number, preceding: UnicodeCharProperties): UnicodeCharProperties { + return this._activeProvider.charProperties(codepoint, preceding); + } +}