diff --git a/src/test.ts b/src/test.ts index 3695ec2..759d451 100644 --- a/src/test.ts +++ b/src/test.ts @@ -8,3 +8,16 @@ const renderer = new CanvasRenderer(main!); const source = new WebSocketSource("ws://localhost:3131"); const input = new KeyboardEventInput(document); const term = new Terminal(source, renderer, input); + +/* + +demo: + +telnet towel.blinkenlights.nl +vi +sl +cmatrix +screenfetch +less + +*/ diff --git a/src/yterm/ansi.ts b/src/yterm/ansi.ts deleted file mode 100644 index 79a6ac0..0000000 --- a/src/yterm/ansi.ts +++ /dev/null @@ -1,156 +0,0 @@ -// base class for ANSI sequence -export enum ANSICommand { - ESC_TITLE = "ESC_TITLE", - ESC_GRAPHIC_RENDITION = "ESC_GRAPHIC_RENDITION", - ESC_ERASE_IN_LINE = "ESC_ERASE_IN_LINE", - ESC_SET_DIMENSIONS = "ESC_SET_DIMENSIONS", - - ESC_CURSOR_MOVE = "ESC_CURSOR_MOVE", - ESC_DELETE_CHAR = "ESC_DELETE_CHAR", - ESC_INSERT_CHAR = "ESC_INSERT_CHAR", - - CTRL_BELL = "CTRL_BELL", - CTRL_BACKSPACE = "CTRL_BACKSPACE", - CTRL_TAB = "CTRL_TAB", - CTRL_CARRIAGE_RETURN = "CTRL_CARRIAGE_RETURN" -}; - -export class ANSISequence { - public cmd: ANSICommand; - public args: Array; - - constructor (cmd: ANSICommand, args: Array) { - this.cmd = cmd; - this.args = args; - } - - toString (): string { - return `escaped ${this.cmd} (${this.args.join(", ")})`; - } -}; - -const escapeSequences = [ - { - pattern: /^\x1b]0;([^\x1b\x07]+)\x07/, - handler: (match: RegExpExecArray) => { - return new ANSISequence(ANSICommand.ESC_TITLE, [match[1]]); - } - }, - - { - pattern: /^\x1b\[(\d*)(A|B|C|D)/, - handler: (match: RegExpExecArray) => { - return new ANSISequence(ANSICommand.ESC_CURSOR_MOVE, [parseInt(match[1] || "1"), match[2]]); - } - }, - - { - pattern: /^\x1b\[((\d+)?(;(\d+)?)*)m/, - handler: (match: RegExpExecArray) => { - const modes = match[1].split(";"); - const args = []; - - for (const mode of modes) { - if (mode === "") { - args.push(0); - } else { - args.push(parseInt(mode)); - } - } - - return new ANSISequence(ANSICommand.ESC_GRAPHIC_RENDITION, args);; - } - }, - - { - pattern: /^\x1b\[(\d?)K/, - handler: (match: RegExpExecArray) => { - return new ANSISequence(ANSICommand.ESC_ERASE_IN_LINE, [parseInt(match[1])]); - } - }, - - { - pattern: /^\x1b\[(\d*)P/, - handler: (match: RegExpExecArray) => { - return new ANSISequence(ANSICommand.ESC_DELETE_CHAR, [parseInt(match[1] || "1")]); - } - }, - - { - pattern: /^\x1b\[(\d*)@/, - handler: (match: RegExpExecArray) => { - return new ANSISequence(ANSICommand.ESC_INSERT_CHAR, [parseInt(match[1] || "1")]); - } - }, - - { - pattern: /^\x1b\[8;(\d+);(\d+)t/, - handler: (match: RegExpExecArray) => { - return new ANSISequence(ANSICommand.ESC_SET_DIMENSIONS, [parseInt(match[1]), parseInt(match[2])]); - } - }, - - { - pattern: /^\x07/, - handler: () => { - return new ANSISequence(ANSICommand.CTRL_BELL, []); - } - }, - - { - pattern: /^\x08/, - handler: () => { - return new ANSISequence(ANSICommand.CTRL_BACKSPACE, []); - } - }, - - { - pattern: /^\x09/, - handler: () => { - return new ANSISequence(ANSICommand.CTRL_TAB, []); - } - }, - - { - pattern: /^\x0d/, - handler: () => { - return new ANSISequence(ANSICommand.CTRL_CARRIAGE_RETURN, []); - } - } -]; - -// parse a data stream as a sequence of chunks and pass them to the handler -export function parseANSIStream (data: string, handler: (chunk: ANSISequence | string) => void) { - // console.log([...data].map(c => c.charCodeAt(0))); - - while (data.length) { - const pos = data.search(/\x1b|\x07|\x08|\x09|\x0d/); - let match = null; - - if (pos == -1) { - handler(data); - break; - } - - if (pos) { - handler(data.substring(0, pos)); - } - - data = data.substring(pos); - - for (const { pattern, handler: lexHandler } of escapeSequences) { - match = pattern.exec(data); - - if (match !== null) { - handler(lexHandler(match)); - data = data.substring(match[0].length); - break; - } - } - - if (!match) { - handler(data.substring(0, 1)); - data = data.substring(1); - } - } -} diff --git a/src/yterm/canvas.ts b/src/yterm/canvas.ts index 86ef003..a4caca3 100644 --- a/src/yterm/canvas.ts +++ b/src/yterm/canvas.ts @@ -1,5 +1,6 @@ import { assert } from "./utils"; -import { Renderer, Block } from "./renderer"; +import { Renderer, Block, Color, Intensity, TextStyle } from "./renderer"; +import { ColorScheme, TangoColorScheme } from "./schemes"; export class Font { private family: string; @@ -13,18 +14,20 @@ export class Font { getFamily () { return this.family; } getSize () { return this.size; } - getContextFont (): string { - return `${this.getSize()}px ${this.getFamily()}`; + getContextFont (style = "normal", weight = "normal"): string { + return `${style} ${weight} ${this.getSize()}px ${this.getFamily()}`; } } export class CanvasRenderer extends Renderer { - static BLOCK_SPILL = 0.1; + static BLOCK_SPILL = 0; static DEFAULT_CURSOR_INTERVAL = 700; static DEFAULT_COLUMNS = 80; static DEFAULT_ROWS = 24; + static DEFAULT_SCHEME = new TangoColorScheme(); + static DEFAULT_FONT = new Font("Ubuntu Mono", 16); private textLayer: HTMLCanvasElement; @@ -40,17 +43,24 @@ export class CanvasRenderer extends Renderer { private fontDescent!: number; // distance from the baseline private screen!: Array>; + private mainScreen: Array> | null; + private mainScreenCursorColumn: number; + private mainScreenCursorRow: number; private cursorColumn: number; private cursorRow: number; private cursorIntervalId: NodeJS.Timeout | null; private cursorInterval: number; + private cursorBlink: boolean; + + private colorScheme: ColorScheme; constructor ( parent: HTMLElement, columns = CanvasRenderer.DEFAULT_COLUMNS, rows = CanvasRenderer.DEFAULT_ROWS, - font = CanvasRenderer.DEFAULT_FONT + font = CanvasRenderer.DEFAULT_FONT, + colorScheme = CanvasRenderer.DEFAULT_SCHEME ) { super(); @@ -63,6 +73,13 @@ export class CanvasRenderer extends Renderer { this.cursorRow = 0; this.cursorIntervalId = null; this.cursorInterval = CanvasRenderer.DEFAULT_CURSOR_INTERVAL; + this.cursorBlink = true; + + this.colorScheme = colorScheme; + + this.mainScreen = null; + this.mainScreenCursorColumn = 0; + this.mainScreenCursorRow = 0; this.setLayout(font, columns, rows); this.showCursor(); @@ -97,8 +114,10 @@ export class CanvasRenderer extends Renderer { this.cursorColumn = column; this.cursorRow = row; - // if the cursor moves, keep it on - this.blinkCursor(true); + // if the cursor moves and the cursor is not disabled, keep it on + if (this.cursorIntervalId !== null) { + this.blinkCursor(true); + } } getCursor (): { column: number, row: number } { @@ -108,6 +127,78 @@ export class CanvasRenderer extends Renderer { } } + showCursor () { + if (this.cursorIntervalId === null) { + let on = true; + + this.cursorIntervalId = setInterval(() => { + this.blinkCursor(on || !this.cursorBlink); + on = !on; + }, this.cursorInterval); + + this.blinkCursor(true); + } + } + + hideCursor () { + if (this.cursorIntervalId !== null) { + clearInterval(this.cursorIntervalId); + this.cursorIntervalId = null; + } + + this.blinkCursor(false); + } + + enableCursorBlink () { + this.cursorBlink = true; + } + + disableCursorBlink () { + this.cursorBlink = false; + + if (this.cursorIntervalId !== null) { + this.blinkCursor(true); + } + } + + useAlternativeScreen () { + if (this.mainScreen === null) { + // save the current screen + this.mainScreen = this.screen; + } // else already in the alternative screen + + // create a new screen of the same size + this.screen = new Array>(this.rows); + + for (let i = 0; i < this.rows; i++) { + this.screen[i] = new Array(this.columns); + } + + const pos = this.getCursor(); + this.mainScreenCursorColumn = pos.column; + this.mainScreenCursorRow = pos.row; + + this.setCursor(0, 0); + + this.renderAll(); + } + + useMainScreen () { + if (this.mainScreen) { + this.screen = this.mainScreen; + + // reset screen since there might be grid changes + // when we are in the alternative screen + this.setLayout(this.font, this.screen[0].length, this.screen.length); + + this.mainScreen = null; + + this.setCursor(this.mainScreenCursorColumn, this.mainScreenCursorRow); + + this.renderAll(); + } // else already in the main screen + } + private setLayout (font: Font, columns: number, rows: number) { assert(columns > 0 && rows > 0, "grid too small"); @@ -128,7 +219,7 @@ export class CanvasRenderer extends Renderer { const newScreen = new Array>(this.rows); - for (let i = 0; i < this.columns; i++) { + for (let i = 0; i < this.rows; i++) { newScreen[i] = new Array(this.columns); } @@ -171,7 +262,8 @@ export class CanvasRenderer extends Renderer { this.screen[this.cursorRow][this.cursorColumn] || this.getDefaultBlock(); - const inverseBlock = new Block(block.getForeground(), block.getBackground(), block.getChar()); + const inverseBlock = block.copy(); + inverseBlock.inversed = true; if (on) { this.renderBlock(inverseBlock, this.cursorColumn, this.cursorRow); @@ -180,24 +272,6 @@ export class CanvasRenderer extends Renderer { } } - private showCursor () { - if (this.cursorIntervalId === null) { - let on = true; - - this.cursorIntervalId = setInterval(() => { - this.blinkCursor(on); - on = !on; - }, this.cursorInterval); - } - } - - private hideCursor () { - if (this.cursorIntervalId !== null) { - clearInterval(this.cursorIntervalId); - this.cursorIntervalId = null; - } - } - // render functions private renderBlock (block: Block, column: number, row: number) { @@ -209,11 +283,60 @@ export class CanvasRenderer extends Renderer { this.textContext.save(); - this.textContext.fillStyle = (block || this.getDefaultBlock()).getBackground(); + block = block || this.getDefaultBlock(); + + let background, foreground, style = "normal", weight = "normal"; + + // render background + if (typeof block.background == "string") { + background = block.background; + } else { + background = this.colorScheme.getSGRBackground(block.background); + } + + // render foreground + if (typeof block.foreground == "string") { + foreground = block.foreground; + } else { + foreground = this.colorScheme.getSGRForeground(block.foreground); + } + + // switch background and foregound if inversed + if (block.inversed) { + [background, foreground] = [foreground, background]; + } + + // render text style + switch (block.style) { + case TextStyle.STYLE_NORMAL: + style = "normal"; + break; + + case TextStyle.STYLE_ITALIC: + style = "italic"; + break; + } + + // render text weight + switch (block.intensity) { + case Intensity.SGR_INTENSITY_HIGH: + weight = "bold"; + break; + + case Intensity.SGR_INTENSITY_LOW: + weight = "lighter"; + break; + + case Intensity.SGR_INTENSITY_NORMAL: + weight = "normal"; + break; + } + + this.textContext.fillStyle = background; this.textContext.fillRect(x - spill, y - spill, this.fontWidth + 2 * spill, this.fontHeight + 2 * spill); // add 0.5 to fill the gap - this.textContext.fillStyle = block.getForeground(); - this.textContext.font = this.font.getContextFont(); + this.textContext.fillStyle = foreground; + this.textContext.font = this.font.getContextFont(style, weight); this.textContext.fillText(block.getChar() || "", x, y + this.fontHeight - this.fontDescent); this.textContext.restore(); diff --git a/src/yterm/control.ts b/src/yterm/control.ts new file mode 100644 index 0000000..fde881c --- /dev/null +++ b/src/yterm/control.ts @@ -0,0 +1,202 @@ +import { regexUnion, regexMatchStart } from "./utils"; + +export class ControlSequence { + public cmd: string; + public args: Array; + + constructor (cmd: string, args: Array) { + this.cmd = cmd; + this.args = args; + } + + toString (): string { + return `control ${this.cmd} (${this.args.join(", ")})`; + } +} + +export class ControlDefinition { + public pattern: RegExp; + public patternMatchStart: RegExp; + + public handler: (match: RegExpExecArray) => ControlSequence; + + constructor (pattern: RegExp, handler: (match: RegExpExecArray) => ControlSequence) { + this.pattern = pattern; + this.patternMatchStart = regexMatchStart(pattern); + this.handler = handler; + } +} + +export const ansiControlSequences = [ + // operating system controls + new ControlDefinition(/\x1b](\d+);([^\x1b\x07]+)\x07/, match => { + return new ControlSequence("CONTROL_OS_CONTROL", [parseInt(match[1]), match[2]]); + }), + + new ControlDefinition(/\x1b\[(\d*)(A|B|C|D|H|F)/, match => { + return new ControlSequence("CONTROL_CURSOR_MOVE", [parseInt(match[1] || "1"), match[2]]); + }), + + new ControlDefinition(/\x1b\[((\d+)?(;(\d+)?)*)m/, match => { + const modes = match[1].split(";"); + const args = []; + + for (const mode of modes) { + if (mode === "") { + args.push(0); + } else { + args.push(parseInt(mode)); + } + } + + return new ControlSequence("CONTROL_GRAPHIC_RENDITION", args); + }), + + new ControlDefinition(/\x1b\[(\d?)K/, match => { + return new ControlSequence("CONTROL_ERASE_IN_LINE", [parseInt(match[1] || "0")]); + }), + + new ControlDefinition(/\x1b\[(\d*)P/, match => { + return new ControlSequence("CONTROL_DELETE_CHAR", [parseInt(match[1] || "1")]); + }), + + new ControlDefinition(/\x1b\[(\d*)L/, match => { + return new ControlSequence("CONTROL_INSERT_LINE", [parseInt(match[1] || "1")]); + }), + + new ControlDefinition(/\x1b\[(\d*)@/, match => { + return new ControlSequence("CONTROL_INSERT_CHAR", [parseInt(match[1] || "1")]); + }), + + // direct cursor addressing + new ControlDefinition(/\x1b\[(\d+);(\d+)(H|f)/, match => { + return new ControlSequence("CONTROL_CURSOR_MOVE_DIRECT", [parseInt(match[1]), parseInt(match[2])]); + }), + + new ControlDefinition(/\x1b\[(\d+)(G|`)/, match => { + return new ControlSequence("CONTROL_CURSOR_HORIZONTAL_POS", [parseInt(match[1])]); + }), + + new ControlDefinition(/\x1b\[(\d+)d/, match => { + return new ControlSequence("CONTROL_CURSOR_VERTICAL_POS", [parseInt(match[1])]); + }), + + new ControlDefinition(/\x1b\(B/, _ => { + return new ControlSequence("CONTROL_ASCII_MODE", []); + }), + + // TODO: might be interesting to look into this one + new ControlDefinition(/\x1b\(0/, _ => { + return new ControlSequence("CONTROL_DEC_LINE_DRAWING_MODE", []); + }), + + new ControlDefinition(/\x1b\[(\d?)J/, match => { + return new ControlSequence("CONTROL_ERASE_IN_DISPLAY", [parseInt(match[1] || "0")]); + }), + + // TODO: what do these control sequences do + new ControlDefinition(/\x1b=/, _ => { + return new ControlSequence("CONTROL_ENABLE_KEYPAD_APP_MODE", []); + }), + + new ControlDefinition(/\x1b>/, _ => { + return new ControlSequence("CONTROL_ENABLE_KEYPAD_NUM_MODE", []); + }), + + new ControlDefinition(/\x1b\[(\??\d+)(h|l)/, match => { + return new ControlSequence("CONTROL_SET_RESET_MODE", [match[1], match[2]]); + }), + + new ControlDefinition(/\x1b\[(\d*);(\d*)r/, match => { + return new ControlSequence("CONTROL_SET_TOP_BOTTOM_MARGIN", [parseInt(match[1] || "0"), parseInt(match[2] || "0")]); + }), + + new ControlDefinition(/\x1b\[(\d*);(\d*);(\d*)t/, match => { + return new ControlSequence("CONTROL_WINDOW_MANIPULATION", [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])]); + }), + + new ControlDefinition(/\x1b\[>c/, _ => { + return new ControlSequence("CONTROL_SEND_DEVICE_ATTR", []); + }), + + new ControlDefinition(/\x1bP([^\x1b]*)\x1b\\/, match => { + return new ControlSequence("CONTROL_DEVICE_CONTROL_STRING", [match[1]]); + }), + + new ControlDefinition(/\x1b7/, _ => { + return new ControlSequence("CONTROL_SAVE_CURSOR", []); + }), + + new ControlDefinition(/\x1b8/, _ => { + return new ControlSequence("CONTROL_RESTORE_CURSOR", []); + }), + + // move cursor up by one row. if the cursor is at the top + // scroll up + new ControlDefinition(/\x1bM/, _ => { + return new ControlSequence("CONTROL_REVERSE_INDEX", []); + }) +]; + +export const asciiControlSequences = [ + new ControlDefinition(/\x07/, () => { + return new ControlSequence("CONTROL_BELL", []); + }), + + new ControlDefinition(/\x08/, () => { + return new ControlSequence("CONTROL_BACKSPACE", []); + }), + + new ControlDefinition(/\x09/, () => { + return new ControlSequence("CONTROL_TAB", []); + }), + + new ControlDefinition(/\x0d/, () => { + return new ControlSequence("CONTROL_CARRIAGE_RETURN", []); + }) +]; + +export class ControlSequenceParser { + private defs: Array; + private unionPattern: RegExp; + + constructor (defs: Array) { + this.defs = defs; + this.unionPattern = regexUnion(...defs.map(d => d.pattern)); // union pattern for fast screening + } + + // TODO: do we need to deal with sequences that are broken up in two chunks of data? + parseStream (data: string, handler: (chunk: ControlSequence | string) => void) { + while (data.length) { + const pos = data.search(this.unionPattern); + + if (pos == -1) { + handler(data); + break; + } + + if (pos) { + handler(data.substring(0, pos)); + } + + data = data.substring(pos); + + // otherwise check every possible sequence + for (const def of this.defs) { + const match = def.patternMatchStart.exec(data); + + if (match !== null) { + handler(def.handler(match)); + data = data.substring(match[0].length); + break; + } + } + + // there should be at least one match, + // otherwise unionRegex is not working correctly + } + } +} + +// containing all sequences currently supporting +export const fullParser = new ControlSequenceParser(ansiControlSequences.concat(asciiControlSequences)); diff --git a/src/yterm/input.ts b/src/yterm/input.ts index 9d5db28..31d9fbb 100644 --- a/src/yterm/input.ts +++ b/src/yterm/input.ts @@ -1,8 +1,11 @@ export class Input { private handlers: Array<(a: string) => void>; + public applicationCursorMode: boolean; + constructor () { this.handlers = []; + this.applicationCursorMode = false; } onInput (handler: (a: string) => void) { @@ -14,6 +17,11 @@ export class Input { handler(data); } } + + // https://the.earth.li/~sgtatham/putty/0.60/htmldoc/Chapter4.html#config-appcursor + setApplicationCursorMode (enable: boolean) { + this.applicationCursorMode = enable; + } }; export class KeyboardEventInput extends Input { @@ -26,6 +34,8 @@ export class KeyboardEventInput extends Input { console.log(keyboardEvent.key, keyboardEvent.charCode, event); const input = (str: string) => { + console.log("inputing", str); + this.input(str); keyboardEvent.preventDefault(); @@ -39,22 +49,42 @@ export class KeyboardEventInput extends Input { switch (keyboardEvent.key) { case "Down": case "ArrowDown": - input("\x1b[B"); + if (this.applicationCursorMode) { + input("\x1bOB"); + } else { + input("\x1b[B"); + } + break; case "Up": case "ArrowUp": - input("\x1b[A"); + if (this.applicationCursorMode) { + input("\x1bOA"); + } else { + input("\x1b[A"); + } + break; case "Left": case "ArrowLeft": - input("\x1b[D"); + if (this.applicationCursorMode) { + input("\x1bOD"); + } else { + input("\x1b[D"); + } + break; case "Right": case "ArrowRight": - input("\x1b[C"); + if (this.applicationCursorMode) { + input("\x1bOC"); + } else { + input("\x1b[C"); + } + break; case "Enter": @@ -63,6 +93,7 @@ export class KeyboardEventInput extends Input { case "Esc": case "Escape": + input("\x1b"); break; case "Control": @@ -89,6 +120,7 @@ export class KeyboardEventInput extends Input { default: if (keyboardEvent.ctrlKey) { + // https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences#numpad--function-keys switch (keyboardEvent.key) { case "d": input("\x04"); @@ -105,6 +137,10 @@ export class KeyboardEventInput extends Input { case "e": input("\x05"); break; + + case "z": + input("\x1a"); + break; } } else { input(keyboardEvent.key); diff --git a/src/yterm/renderer.ts b/src/yterm/renderer.ts index 7ecdb7b..25c5470 100644 --- a/src/yterm/renderer.ts +++ b/src/yterm/renderer.ts @@ -1,36 +1,146 @@ import { assert } from "./utils"; import { UnicodeChar, unicodeLength } from "./unicode"; -export type Color = string; +// select graphic rendition code +// https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters +export enum SGRAttribute { + SGR_RESET = 0, + + SGR_HIGH_INTENSITY = 1, // bold + SGR_LOW_INTENSITY = 2, // faint + SGR_NORMAL_INTENSITY = 22, + + SGR_ITALIC_STYLE = 3, + SGR_FRAKTUR_STYLE = 20, + SGR_NORMAL_STYLE = 23, + + SGR_UNDERLINE_ON = 4, + SGR_DOUBLY_UNDERLINE = 21, + SGR_UNDERLINE_OFF = 24, + + SGR_SLOW_BLINK = 5, + SGR_RAPID_BLINK = 6, + SGR_BLINK_OFF = 25, + + SGR_REVERSE = 7, + SGR_REVERSE_OFF = 27, + + SGR_CONCEAL = 8, + SGR_REVEAL = 28, + + SGR_CROSSED_OUT = 9, + SGR_NOT_CROSSED_OUT = 29, + + SGR_DEFAULT_FONT = 10, + SGR_ALTERNATIVE_FONT_1 = 11, + SGR_ALTERNATIVE_FONT_2 = 12, + SGR_ALTERNATIVE_FONT_3 = 13, + SGR_ALTERNATIVE_FONT_4 = 14, + SGR_ALTERNATIVE_FONT_5 = 15, + SGR_ALTERNATIVE_FONT_6 = 16, + SGR_ALTERNATIVE_FONT_7 = 17, + SGR_ALTERNATIVE_FONT_8 = 18, + SGR_ALTERNATIVE_FONT_9 = 19, + + SGR_FOREGROUND_BLACK = 30, + SGR_FOREGROUND_RED = 31, + SGR_FOREGROUND_GREEN = 32, + SGR_FOREGROUND_YELLOW = 33, + SGR_FOREGROUND_BLUE = 34, + SGR_FOREGROUND_MAGENTA = 35, + SGR_FOREGROUND_CYAN = 36, + SGR_FOREGROUND_WHITE = 37, + SGR_FOREGROUND_CUSTOM = 38, + SGR_FOREGROUND_DEFAULT = 39, + + SGR_BACKGROUND_BLACK = 40, + SGR_BACKGROUND_RED = 41, + SGR_BACKGROUND_GREEN = 42, + SGR_BACKGROUND_YELLOW = 43, + SGR_BACKGROUND_BLUE = 44, + SGR_BACKGROUND_MAGENTA = 45, + SGR_BACKGROUND_CYAN = 46, + SGR_BACKGROUND_WHITE = 47, + SGR_BACKGROUND_CUSTOM = 48, + SGR_BACKGROUND_DEFAULT = 49 +} + +export type Color = SGRColor | string; + +export enum SGRColor { + SGR_COLOR_BLACK, + SGR_COLOR_RED, + SGR_COLOR_GREEN, + SGR_COLOR_YELLOW, + SGR_COLOR_BLUE, + SGR_COLOR_MAGENTA, + SGR_COLOR_CYAN, + SGR_COLOR_WHITE, + SGR_COLOR_DEFAULT +} + +export enum Intensity { + SGR_INTENSITY_NORMAL, + SGR_INTENSITY_HIGH, + SGR_INTENSITY_LOW +} + +export enum BlinkStatus { + BLINK_NONE, + BLINK_SLOW, + BLINK_FAST +} + +export enum TextStyle { + STYLE_NORMAL, + STYLE_ITALIC +} // block represents a single character block on the terminal // containing all style related data export class Block { - private background: Color; - private foreground: Color; private char: UnicodeChar | null; - constructor (background: Color, foreground: Color, char: UnicodeChar | null) { - assert(char == null || unicodeLength(char) == 1, `${char} is not a single character`); + public background: Color; + public foreground: Color; + public intensity: Intensity; + public blink: BlinkStatus; + public inversed: boolean; + public style: TextStyle; + + constructor (char = null, + background = SGRColor.SGR_COLOR_DEFAULT, + foreground = SGRColor.SGR_COLOR_DEFAULT) { + assert(char == null || unicodeLength(char!) == 1, `${char} is not a single character`); + + this.char = char; this.background = background; this.foreground = foreground; - this.char = char; + + this.intensity = Intensity.SGR_INTENSITY_NORMAL; + this.blink = BlinkStatus.BLINK_NONE; + this.inversed = false; + this.style = TextStyle.STYLE_NORMAL; + } + + copy (): Block { + return Object.create(this); } - getBackground () { return this.background; } - getForeground () { return this.foreground; } getChar () { return this.char; } withChar (char: UnicodeChar | null) { - return new Block(this.background, this.foreground, char); + const copy = this.copy(); + copy.char = char; + return copy; } } export abstract class Renderer { private defaultBlock: Block; // default block is equivalent to a null block - constructor (defaultBlock = new Block("#000", "#fff", null)) { + constructor (defaultBlock = new Block()) { this.defaultBlock = defaultBlock; } @@ -46,6 +156,15 @@ export abstract class Renderer { abstract setCursor (column: number, row: number): void; abstract getCursor (): { column: number, row: number }; + // optional + showCursor () {} + hideCursor () {} + enableCursorBlink () {} + disableCursorBlink () {} + + useAlternativeScreen () {} + useMainScreen () {} + setDefaultBlock (block: Block) { this.defaultBlock = block; } @@ -72,29 +191,162 @@ export abstract class Renderer { } // overwriting scrollDown may be more efficient - scrollDown (n: number): void { - assert(n >= 0, "scrolling down by a negative number"); + // positive number to scroll down + // negative to scroll up + scroll (n: number) { + if (n == 0) { + return; + } const { columns, rows } = this.getGridSize(); + let scrollUp = false; - if (n == 0) { - return; + if (n < 0) { + n = -n; + scrollUp = true; } if (n > rows) { n = rows; } - for (let i = 0; i < rows; i++) { - for (let j = 0; j < columns; j++) { - if (i < rows - n) { - // move first (rows - n) rows up by n - this.setBlock(this.getBlock(j, i + n), j, i); - } else { - // clear the rest - this.setBlock(null, j, i); + if (scrollUp) { + for (let i = rows - 1; i >= 0; i--) { + for (let j = 0; j < columns; j++) { + if (i < n) { + // clear the first n rows + this.setBlock(null, j, i); + } else { + // move first n rows down by n + this.setBlock(this.getBlock(j, i - n), j, i); + } + } + } + } else { + for (let i = 0; i < rows; i++) { + for (let j = 0; j < columns; j++) { + if (i < rows - n) { + // move first (rows - n) rows up by n + this.setBlock(this.getBlock(j, i + n), j, i); + } else { + // clear the rest + this.setBlock(null, j, i); + } } } } } } + +export function applySGRAttribute (attrs: Array, block: Block): Block { + let final = block.copy(); + + for (const attr of attrs) { + switch (attr) { + case SGRAttribute.SGR_RESET: + final = new Block(); // reset the block + break; + + // intensity + case SGRAttribute.SGR_HIGH_INTENSITY: + final.intensity = Intensity.SGR_INTENSITY_HIGH; + break; + + case SGRAttribute.SGR_LOW_INTENSITY: + final.intensity = Intensity.SGR_INTENSITY_LOW; + break; + + case SGRAttribute.SGR_NORMAL_INTENSITY: + final.intensity = Intensity.SGR_INTENSITY_NORMAL; + break; + + // style + case SGRAttribute.SGR_ITALIC_STYLE: + final.style = TextStyle.STYLE_ITALIC; + break; + + case SGRAttribute.SGR_NORMAL_STYLE: + final.style = TextStyle.STYLE_NORMAL; + break; + + // foreground colors + case SGRAttribute.SGR_FOREGROUND_BLACK: + final.foreground = SGRColor.SGR_COLOR_BLACK; + break; + + case SGRAttribute.SGR_FOREGROUND_RED: + final.foreground = SGRColor.SGR_COLOR_RED; + break; + + case SGRAttribute.SGR_FOREGROUND_GREEN: + final.foreground = SGRColor.SGR_COLOR_GREEN; + break; + + case SGRAttribute.SGR_FOREGROUND_YELLOW: + final.foreground = SGRColor.SGR_COLOR_YELLOW; + break; + + case SGRAttribute.SGR_FOREGROUND_BLUE: + final.foreground = SGRColor.SGR_COLOR_BLUE; + break; + + case SGRAttribute.SGR_FOREGROUND_MAGENTA: + final.foreground = SGRColor.SGR_COLOR_MAGENTA; + break; + + case SGRAttribute.SGR_FOREGROUND_CYAN: + final.foreground = SGRColor.SGR_COLOR_CYAN; + break; + + case SGRAttribute.SGR_FOREGROUND_WHITE: + final.foreground = SGRColor.SGR_COLOR_WHITE; + break; + + case SGRAttribute.SGR_FOREGROUND_DEFAULT: + final.foreground = SGRColor.SGR_COLOR_DEFAULT; + break; + + // background colors + case SGRAttribute.SGR_BACKGROUND_BLACK: + final.foreground = SGRColor.SGR_COLOR_BLACK; + break; + + case SGRAttribute.SGR_BACKGROUND_RED: + final.foreground = SGRColor.SGR_COLOR_RED; + break; + + case SGRAttribute.SGR_BACKGROUND_GREEN: + final.foreground = SGRColor.SGR_COLOR_GREEN; + break; + + case SGRAttribute.SGR_BACKGROUND_YELLOW: + final.foreground = SGRColor.SGR_COLOR_YELLOW; + break; + + case SGRAttribute.SGR_BACKGROUND_BLUE: + final.foreground = SGRColor.SGR_COLOR_BLUE; + break; + + case SGRAttribute.SGR_BACKGROUND_MAGENTA: + final.foreground = SGRColor.SGR_COLOR_MAGENTA; + break; + + case SGRAttribute.SGR_BACKGROUND_CYAN: + final.foreground = SGRColor.SGR_COLOR_CYAN; + break; + + case SGRAttribute.SGR_BACKGROUND_WHITE: + final.foreground = SGRColor.SGR_COLOR_WHITE; + break; + + case SGRAttribute.SGR_BACKGROUND_DEFAULT: + final.foreground = SGRColor.SGR_COLOR_DEFAULT; + break; + + default: + console.log(`SGR attribute ${attr} ignored`); + } + } + + return final; +} diff --git a/src/yterm/schemes.ts b/src/yterm/schemes.ts new file mode 100644 index 0000000..bebe79b --- /dev/null +++ b/src/yterm/schemes.ts @@ -0,0 +1,39 @@ +import { SGRColor } from "./renderer"; + +export interface ColorScheme { + getSGRForeground (color: SGRColor): string; + getSGRBackground (color: SGRColor): string; +} + +export class TangoColorScheme implements ColorScheme { + static FOREGROUND_PALETTE: Record = { + [SGRColor.SGR_COLOR_BLACK]: "#2e3436", + [SGRColor.SGR_COLOR_RED]: "#a40000", + [SGRColor.SGR_COLOR_GREEN]: "#8ae234", + [SGRColor.SGR_COLOR_YELLOW]: "#c4a000", + [SGRColor.SGR_COLOR_BLUE]: "#729fcf", + [SGRColor.SGR_COLOR_MAGENTA]: "#5c3565", + [SGRColor.SGR_COLOR_CYAN]: "#3465a4", + [SGRColor.SGR_COLOR_WHITE]: "#eeeeec", + [SGRColor.SGR_COLOR_DEFAULT]: "#eeeeec" + }; + + static BACKGROUND_PALETTE: Record = { + [SGRColor.SGR_COLOR_BLACK]: "#2e3436", + [SGRColor.SGR_COLOR_RED]: "#a40000", + [SGRColor.SGR_COLOR_GREEN]: "#8ae234", + [SGRColor.SGR_COLOR_YELLOW]: "#c4a000", + [SGRColor.SGR_COLOR_BLUE]: "#729fcf", + [SGRColor.SGR_COLOR_MAGENTA]: "#5c3565", + [SGRColor.SGR_COLOR_CYAN]: "#3465a4", + [SGRColor.SGR_COLOR_WHITE]: "#eeeeec", + [SGRColor.SGR_COLOR_DEFAULT]: "#2e3436" + }; + + getSGRForeground (color: SGRColor) { + return TangoColorScheme.FOREGROUND_PALETTE[color]; + } + getSGRBackground (color: SGRColor) { + return TangoColorScheme.BACKGROUND_PALETTE[color]; + } +} diff --git a/src/yterm/terminal.ts b/src/yterm/terminal.ts index 050b84f..852124a 100644 --- a/src/yterm/terminal.ts +++ b/src/yterm/terminal.ts @@ -1,25 +1,30 @@ -import { parseANSIStream, ANSISequence, ANSICommand } from "./ansi"; +import { fullParser, ControlSequence, ControlSequenceParser } from "./control"; import { Source } from "./source"; import { Input } from "./input"; -import { Renderer } from "./renderer"; +import { Renderer, Block, SGRAttribute, Intensity, SGRColor, applySGRAttribute } from "./renderer"; import { assert } from "./utils"; export class Terminal { private source: Source; private renderer: Renderer; private input: Input; + private controlParser: ControlSequenceParser; + + private savedCursorColumn: number; + private savedCursorRow: number; constructor (source: Source, renderer: Renderer, input: Input) { this.source = source; this.renderer = renderer; this.input = input; + this.controlParser = fullParser; this.renderer.setCursor(0, 0); this.source.onData(data => { - parseANSIStream(data, chunk => { - if (chunk instanceof ANSISequence) { - this.handleANSISequence(chunk); + this.controlParser.parseStream(data, chunk => { + if (chunk instanceof ControlSequence) { + this.handleControlSequence(chunk); } else { this.printText(chunk); } @@ -30,6 +35,9 @@ export class Terminal { this.source.write(data); }); + this.savedCursorColumn = -1; + this.savedCursorRow = -1; + // ESC [ 8 ; Ph ; Pw t // signal bash to change dimension // this.source.write(`\x1b[8;${this.columns};${this.rows}t`); @@ -41,7 +49,7 @@ export class Terminal { if (this.renderer.isInRange(0, row + 1)) { this.renderer.setCursor(0, row + 1); } else { - this.renderer.scrollDown(1); + this.renderer.scroll(1); this.renderer.setCursor(0, row); } } @@ -55,6 +63,23 @@ export class Terminal { } } + eraseInScreen (fromColumn: number, fromRow: number, + toColumn: number, toRow: number) { + const { columns, rows } = this.renderer.getGridSize(); + + for (let row = fromRow; row < rows; row++) { + for (let column = row == fromRow ? fromColumn : 0; + column < columns; column++) { + + this.renderer.printLetter(null, column, row); + + if (column == toColumn && row == toRow) { + return; + } + } + } + } + // delete a chunk of characters and move the following parts back deleteInRow (row: number, from: number, to: number) { const { columns } = this.renderer.getGridSize(); @@ -98,6 +123,7 @@ export class Terminal { cursorMove (n: number, action: string) { const { column, row } = this.renderer.getCursor(); + const { columns, rows } = this.renderer.getGridSize(); switch (action) { case "A": // up @@ -124,55 +150,85 @@ export class Terminal { } break; + case "H": // home (0, 0) + this.renderer.setCursor(0, 0); + break; + + case "F": // end + this.renderer.setCursor(columns - 1, rows - 1); + break; + default: assert(false, `unsupported action ${action}`); } } - handleANSISequence (seq: ANSISequence) { + handleControlSequence (seq: ControlSequence) { console.log(seq.toString()); switch (seq.cmd) { - case ANSICommand.CTRL_BACKSPACE: + case "CONTROL_OS_CONTROL": { + const [ code, param ] = seq.args; + + switch (code) { + case 0: + console.log(`title changed to ${param}`); + break; + + default: + console.log(`unsupported os control ${code};${param}`); + } + + break; + } + + case "CONTROL_BACKSPACE": this.cursorMove(1, "D"); // move left for 1 unit break; - case ANSICommand.ESC_CURSOR_MOVE: { + case "CONTROL_CURSOR_MOVE": { const [ n, action ] = seq.args; this.cursorMove(n, action); break; } - case ANSICommand.ESC_DELETE_CHAR: { + case "CONTROL_DELETE_CHAR": { const [ n ] = seq.args; const { column, row } = this.renderer.getCursor(); this.deleteInRow(row, column, column + n - 1); break; } - case ANSICommand.CTRL_TAB: + case "CONTROL_TAB": this.printText(" "); break; - case ANSICommand.CTRL_CARRIAGE_RETURN: + case "CONTROL_CARRIAGE_RETURN": const { row } = this.renderer.getCursor(); this.renderer.setCursor(0, row); break; - case ANSICommand.ESC_SET_DIMENSIONS: { - const [ rows, columns ] = seq.args; - - if (rows > 0 && columns > 0) { - this.renderer.setGridSize(columns, rows); + case "CONTROL_ERASE_IN_DISPLAY": { + const [ code ] = seq.args; + const { columns, rows } = this.renderer.getGridSize(); + const { column: cursorColumn, row: cursorRow } = this.renderer.getCursor(); + + if (code == 0) { + this.eraseInScreen(cursorColumn, cursorRow, columns - 1, rows - 1); + } else if (code == 1) { + this.eraseInScreen(0, 0, cursorColumn, cursorRow); + } else if (code == 2) { + // erase entire screen + this.eraseInScreen(0, 0, columns - 1, rows - 1); } break; } - case ANSICommand.ESC_ERASE_IN_LINE: { + case "CONTROL_ERASE_IN_LINE": { const [ code ] = seq.args; - if (isNaN(code) || code == 0) { + if (code == 0) { const { column, row } = this.renderer.getCursor(); this.eraseInRow(row, column, this.renderer.getGridSize().columns - 1); } else { @@ -182,7 +238,15 @@ export class Terminal { break; } - case ANSICommand.ESC_INSERT_CHAR: { + // case "CONTROL_INSERT_LINE": { + // const [ n ] = seq.args; + // const { column, row } = this.renderer.getCursor(); + // this.insertLine(row, n); + + // break; + // } + + case "CONTROL_INSERT_CHAR": { const [ n ] = seq.args; const { column, row } = this.renderer.getCursor(); this.insertInRow(row, column, n); @@ -190,6 +254,154 @@ export class Terminal { break; } + case "CONTROL_GRAPHIC_RENDITION": { + const newDefault = applySGRAttribute(seq.args, this.renderer.getDefaultBlock()); + this.renderer.setDefaultBlock(newDefault); + break; + } + + case "CONTROL_CURSOR_MOVE_DIRECT": { + let [ row, column ] = seq.args; + + row -= 1; + column -= 1; + + if (this.renderer.isInRange(column, row)) { + this.renderer.setCursor(column, row); + } + + break; + } + + case "CONTROL_CURSOR_HORIZONTAL_POS": { + let [ column ] = seq.args; + const { row } = this.renderer.getCursor(); + + column -= 1; + + if (this.renderer.isInRange(column, row)) { + this.renderer.setCursor(column, row); + } + + break; + } + + case "CONTROL_CURSOR_VERTICAL_POS": { + let [ row ] = seq.args; + const { column } = this.renderer.getCursor(); + + row -= 1; + + if (this.renderer.isInRange(column, row)) { + this.renderer.setCursor(column, row); + } + + break; + } + + case "CONTROL_SET_RESET_MODE": { + const [ mode, action ] = seq.args; + + switch (mode + action) { + case "?12h": + this.renderer.enableCursorBlink(); + break; + + case "?12l": + this.renderer.disableCursorBlink(); + break; + + case "?25h": + this.renderer.showCursor(); + break; + + case "?25l": + this.renderer.hideCursor(); + break; + + case "?1049h": + this.renderer.useAlternativeScreen(); + break; + + case "?1049l": + this.renderer.useMainScreen(); + break; + + // turn on/off application cursor keys mode + // https://the.earth.li/~sgtatham/putty/0.60/htmldoc/Chapter4.html#config-appcursor + case "?1h": + this.input.setApplicationCursorMode(true); + break; + + case "?1l": + this.input.setApplicationCursorMode(false); + break; + + case "?2004": + // bracketed paste mode + // https://cirw.in/blog/bracketed-paste + + default: + console.log(`unsupported mode ${mode + action}`); + } + + break; + } + + case "CONTROL_SAVE_CURSOR": { + const { column, row } = this.renderer.getCursor(); + this.savedCursorColumn = column; + this.savedCursorRow = row; + break; + } + + case "CONTROL_RESTORE_CURSOR": { + if (this.renderer.isInRange(this.savedCursorColumn, this.savedCursorRow)) { + this.renderer.setCursor(this.savedCursorColumn, this.savedCursorRow); + } + + break; + } + + case "CONTROL_REVERSE_INDEX": { + const { column, row } = this.renderer.getCursor(); + + if (row == 0) { + // scroll up + this.renderer.scroll(-1); + } else { + this.renderer.setCursor(column, row - 1); + } + } + + case "CONTROL_WINDOW_MANIPULATION": { + const [ code, a, b ] = seq.args; + + switch (code) { + case 8: + // resize window + if (a > 0 && b > 0) { + this.renderer.setGridSize(b, a); + } + + default: + console.log(`unknown window manipulation code ${code};${a};${b}`); + } + + break; + } + + case "CONTROL_SET_TOP_BOTTOM_MARGIN": + // TODO: support top/bottom margin + this.renderer.setCursor(0, 0); + break; + + case "CONTROL_SEND_DEVICE_ATTR": + // TODO: figure out what this ID means + console.log("device id requested"); + this.source.write("\x1b[>1;5202;0c"); + break; + default: console.log("ignored", seq); } diff --git a/src/yterm/utils.ts b/src/yterm/utils.ts index baeeaf5..6522272 100644 --- a/src/yterm/utils.ts +++ b/src/yterm/utils.ts @@ -13,3 +13,17 @@ export class Pair { this.snd = snd; } } + +// return a regex representing the union of two languages +export function regexUnion(...rs: Array) { + if (rs.length == 0) { + return /$.^/; // a regex that matches nothing () + } + + // otherwise concatenating all patterns with union + return new RegExp(rs.map(r => `(${r.source})`).join("|")); +} + +export function regexMatchStart(r: RegExp) { + return new RegExp("^" + r.source); +} diff --git a/tests/basic.test.ts b/tests/basic.test.ts index 4e506b4..24ec591 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -1,18 +1,18 @@ import { describe, it } from "mocha"; import { expect } from "chai"; -import { ANSISequence, parseANSIStream, ANSICommand } from "../src/yterm/ansi"; +import { fullParser, ControlSequence } from "../src/yterm/control"; -function parseToChunks (data: string): Array { - const chunks: Array = []; +function parseToChunks (data: string): Array { + const chunks: Array = []; const compressedChunks = []; // collapsing consecutive strings together - parseANSIStream(data, chunks.push.bind(chunks)); + fullParser.parseStream(data, chunks.push.bind(chunks)); let standingChunk = ""; for (const chunk of chunks) { - if (chunk instanceof ANSISequence) { + if (chunk instanceof ControlSequence) { if (standingChunk !== "") { compressedChunks.push(standingChunk); standingChunk = "";