diff --git a/src/program/Clear.ts b/src/program/Clear.ts index ffcb325..a5c69db 100644 --- a/src/program/Clear.ts +++ b/src/program/Clear.ts @@ -4,7 +4,7 @@ import { Program } from './Program' export class Clear extends Program { async Exec(_: SimpleStream, stdout: SimpleStream, __: Item, ___: string[]): Promise { - stdout.emit('\f') + stdout.emit('\x1b[2J\x1b[H') return 0 } diff --git a/src/program/Loadprg.ts b/src/program/Loadprg.ts index 4f17714..feff457 100644 --- a/src/program/Loadprg.ts +++ b/src/program/Loadprg.ts @@ -11,7 +11,7 @@ export class Loadprg extends Program { this.shell = shell } - async Exec(_: SimpleStream, stdout: SimpleStream, workdir: Item, args: string[]): Promise { + async Exec(_: SimpleStream, stdout: SimpleStream, __: Item, args: string[]): Promise { const javascript = args.slice(2).join(' ') try { diff --git a/src/program/Sl.ts b/src/program/Sl.ts index b374270..c5ac201 100644 --- a/src/program/Sl.ts +++ b/src/program/Sl.ts @@ -1,51 +1,43 @@ -// --- `sl` Command Implementation --- +import { GetCurrentTerminal } from '../app' +import type { Item } from '../fs/Item' +import type { SimpleStream } from '../utils/SimpleStream' +import { Program } from './Program' -import type { Item } from "../fs/Item" -import type { SimpleStream } from "../utils/SimpleStream" -import { Program } from "./Program" +type SmokeState = { + y: number + x: number + ptrn: number + kind: number +} + +type SlOptions = { + accident: boolean + fly: boolean + logo: boolean + c51: boolean +} export class Sl extends Program { - // The classic D51 locomotive ASCII art with 3 frames of wheel animation. - // Notice the trailing space on each line: this acts as an automatic "eraser" - // for the previous frame as the train moves left! - private static readonly D51_FRAMES: string[][] = [ - [ - ' ==== ________ ___________ ', - ' _D _| |_______/ \\__I_I_____===__|_________| ', - ' |(_)--- | H\\________/ | | =|___ ___| ', - ' / | | H | | | | ||_| |_|| ', - ' | | | H |__--------------------| [___] | ', - ' | ________|___H__/__|_____/[][]~\\_______| | ', - ' |/ | |-----------I_____I [][] [] D |=======| ', - '__/ =| o |=-O=====O=====O=====O \\ ____Y___________| ', - ' |/-=|___|= || || || |_____/~\\___/ ', - ' \\_/ \\__/ \\__/ \\__/ \\__/ \\_/ ', - ], - [ - ' ==== ________ ___________ ', - ' _D _| |_______/ \\__I_I_____===__|_________| ', - ' |(_)--- | H\\________/ | | =|___ ___| ', - ' / | | H | | | | ||_| |_|| ', - ' | | | H |__--------------------| [___] | ', - ' | ________|___H__/__|_____/[][]~\\_______| | ', - ' |/ | |-----------I_____I [][] [] D |=======| ', - '__/ =| o |=-~O====O====O====O~ \\ ____Y___________| ', - ' |/-=|___|= || || || |_____/~\\___/ ', - ' \\_/ \\__/ \\__/ \\__/ \\__/ \\_/ ', - ], - [ - ' ==== ________ ___________ ', - ' _D _| |_______/ \\__I_I_____===__|_________| ', - ' |(_)--- | H\\________/ | | =|___ ___| ', - ' / | | H | | | | ||_| |_|| ', - ' | | | H |__--------------------| [___] | ', - ' | ________|___H__/__|_____/[][]~\\_______| | ', - ' |/ | |-----------I_____I [][] [] D |=======| ', - '__/ =| o |=-~~O===O===O===O~~ \\ ____Y___________| ', - ' |/-=|___|= || || || |_____/~\\___/ ', - ' \\_/ \\__/ \\__/ \\__/ \\__/ \\_/ ', - ], - ] + private static readonly D51_HEIGHT = 10 + private static readonly D51_FUNNEL = 7 + private static readonly D51_LENGTH = 83 + private static readonly D51_PATTERNS = 6 + + private static readonly LOGO_HEIGHT = 6 + private static readonly LOGO_FUNNEL = 4 + private static readonly LOGO_LENGTH = 84 + private static readonly LOGO_PATTERNS = 6 + + private static readonly C51_HEIGHT = 11 + private static readonly C51_FUNNEL = 7 + private static readonly C51_LENGTH = 87 + private static readonly C51_PATTERNS = 6 + + private static readonly SMOKE_PATTERNS = 16 + private static readonly MAX_SMOKE = 1000 + + private smokes: SmokeState[] = [] + private smokeSum = 0 public async Exec( _: SimpleStream, @@ -53,67 +45,588 @@ export class Sl extends Program { __: Item, args: string[], ): Promise { - // Original `sl` behavior flags - const isFly = args.includes('-F') + const options = this.parseOptions(args) + const terminal = GetCurrentTerminal() + const cols = Math.max(1, Math.floor(terminal.GetWidthCells())) + const lines = Math.max(1, Math.floor(terminal.GetHeightCells())) - // Terminal size assumptions since they aren't provided by the API - const termWidth = 100 - const trainWidth = Sl.D51_FRAMES[0][0].length + this.resetSmoke() - // The train starts off-screen to the right and ends completely off-screen to the left - const startX = termWidth - const endX = -trainWidth + // Clear screen and move cursor home (ANSI CSI) + stdout.emit('\x1b[2J\x1b[H') - // Clear screen (new page using form feed) - stdout.emit('\f') + for (let x = cols - 1; ; --x) { + const ok = options.logo + ? this.addSl(x, stdout, cols, lines, options) + : options.c51 + ? this.addC51(x, stdout, cols, lines, options) + : this.addD51(x, stdout, cols, lines, options) - let frameIdx = 0 - for (let x = startX; x >= endX; x--) { - const frame = Sl.D51_FRAMES[frameIdx % Sl.D51_FRAMES.length] + if (!ok) + break - // If the `-F` flag is passed, the train "flies" upwards as it moves forward - let startY = isFly ? Math.floor(x / 4) + 2 : 5 - - for (let y = 0; y < frame.length; y++) { - const line = frame[y] - let outLine = line - let cursorX = x - - // When the train hits the left edge, we must truncate the string - // and lock the drawing cursor to X=0 to prevent terminal wrapping artifacts - if (x < 0) { - cursorX = 0 - outLine = line.substring(-x) - } - - const cursorY = startY + y - - // Only render if within vertical bounds and there's text left to draw - if (cursorY >= 0 && outLine.length > 0) { - // Send absolute cursor positioning sequence - stdout.emit(`\0cma;${cursorX};${cursorY}\0`) - // Render the frame line - stdout.emit(outLine) - } - } - - // Artificial delay to pace the animation await this.sleep(40) - frameIdx++ - - console.log(frameIdx) } - // Return the cursor back to a safe position to give shell control back seamlessly - stdout.emit(`\0cma;0;20\0\n`) - - return 0 // POSIX successful exit code + stdout.emit(`\x1b[${Math.max(1, lines)};1H\n`) + return 0 + } + + private parseOptions(args: string[]): SlOptions { + const options: SlOptions = { + accident: false, + fly: false, + logo: false, + c51: false, + } + + for (const arg of args.slice(1)) { + if (!arg.startsWith('-')) + continue + + for (const flag of arg.slice(1)) { + switch (flag) { + case 'a': + options.accident = true + break + case 'F': + options.fly = true + break + case 'l': + options.logo = true + break + case 'c': + options.c51 = true + break + } + } + } + + return options + } + + private addSl( + x: number, + stdout: SimpleStream, + cols: number, + lines: number, + options: SlOptions, + ): boolean { + if (x < -Sl.LOGO_LENGTH) + return false + + let y = Math.max(0, Math.floor(lines / 2) - 3) + let py1 = 0 + let py2 = 0 + let py3 = 0 + + if (options.fly) { + y = Math.trunc(x / 6) + lines - Math.trunc(cols / 6) - Sl.LOGO_HEIGHT + py1 = 2 + py2 = 4 + py3 = 6 + } + + const pattern = Math.floor((Sl.LOGO_LENGTH + x) / 3) % Sl.LOGO_PATTERNS + + for (let i = 0; i <= Sl.LOGO_HEIGHT; i++) { + this.drawString(stdout, cols, lines, y + i, x, Sl.LOGO[pattern][i]) + this.drawString(stdout, cols, lines, y + i + py1, x + 21, Sl.LOGO_COAL[i]) + this.drawString(stdout, cols, lines, y + i + py2, x + 42, Sl.LOGO_CAR[i]) + this.drawString(stdout, cols, lines, y + i + py3, x + 63, Sl.LOGO_CAR[i]) + } + + if (options.accident) { + this.addMan(stdout, cols, lines, y + 1, x + 14) + this.addMan(stdout, cols, lines, y + 1 + py2, x + 45) + this.addMan(stdout, cols, lines, y + 1 + py2, x + 53) + this.addMan(stdout, cols, lines, y + 1 + py3, x + 66) + this.addMan(stdout, cols, lines, y + 1 + py3, x + 74) + } + + this.addSmoke(stdout, cols, lines, y - 1, x + Sl.LOGO_FUNNEL) + return true + } + + private addD51( + x: number, + stdout: SimpleStream, + cols: number, + lines: number, + options: SlOptions, + ): boolean { + if (x < -Sl.D51_LENGTH) + return false + + let y = Math.max(0, Math.floor(lines / 2) - 5) + let dy = 0 + + if (options.fly) { + y = Math.trunc(x / 7) + lines - Math.trunc(cols / 7) - Sl.D51_HEIGHT + dy = 1 + } + + const pattern = (Sl.D51_LENGTH + x) % Sl.D51_PATTERNS + + for (let i = 0; i <= Sl.D51_HEIGHT; i++) { + this.drawString(stdout, cols, lines, y + i, x, Sl.D51[pattern][i]) + this.drawString(stdout, cols, lines, y + i + dy, x + 53, Sl.D51_COAL[i]) + } + + if (options.accident) { + this.addMan(stdout, cols, lines, y + 2, x + 43) + this.addMan(stdout, cols, lines, y + 2, x + 47) + } + + this.addSmoke(stdout, cols, lines, y - 1, x + Sl.D51_FUNNEL) + return true + } + + private addC51( + x: number, + stdout: SimpleStream, + cols: number, + lines: number, + options: SlOptions, + ): boolean { + if (x < -Sl.C51_LENGTH) + return false + + let y = Math.max(0, Math.floor(lines / 2) - 5) + let dy = 0 + + if (options.fly) { + y = Math.trunc(x / 7) + lines - Math.trunc(cols / 7) - Sl.C51_HEIGHT + dy = 1 + } + + const pattern = (Sl.C51_LENGTH + x) % Sl.C51_PATTERNS + + for (let i = 0; i <= Sl.C51_HEIGHT; i++) { + this.drawString(stdout, cols, lines, y + i, x, Sl.C51[pattern][i]) + this.drawString(stdout, cols, lines, y + i + dy, x + 55, Sl.C51_COAL[i]) + } + + if (options.accident) { + this.addMan(stdout, cols, lines, y + 3, x + 45) + this.addMan(stdout, cols, lines, y + 3, x + 49) + } + + this.addSmoke(stdout, cols, lines, y - 1, x + Sl.C51_FUNNEL) + return true + } + + private addMan( + stdout: SimpleStream, + cols: number, + lines: number, + y: number, + x: number, + ) { + const man = [['', '(O)'], ['Help!', '\\O/']] + const index = Math.floor((Sl.LOGO_LENGTH + x) / 12) % 2 + + for (let i = 0; i < 2; i++) + this.drawString(stdout, cols, lines, y + i, x, man[index][i]) + } + + private addSmoke( + stdout: SimpleStream, + cols: number, + lines: number, + y: number, + x: number, + ) { + if (x % 4 !== 0) + return + + for (let i = 0; i < this.smokeSum; i++) { + const smoke = this.smokes[i] + + this.drawString(stdout, cols, lines, smoke.y, smoke.x, Sl.SMOKE_ERASER[smoke.ptrn]) + smoke.y -= Sl.SMOKE_DY[smoke.ptrn] + smoke.x += Sl.SMOKE_DX[smoke.ptrn] + smoke.ptrn = smoke.ptrn < Sl.SMOKE_PATTERNS - 1 ? smoke.ptrn + 1 : smoke.ptrn + this.drawString(stdout, cols, lines, smoke.y, smoke.x, Sl.SMOKE[smoke.kind][smoke.ptrn]) + } + + this.drawString(stdout, cols, lines, y, x, Sl.SMOKE[this.smokeSum % 2][0]) + + if (this.smokeSum < Sl.MAX_SMOKE) { + this.smokes[this.smokeSum] = { y, x, ptrn: 0, kind: this.smokeSum % 2 } + this.smokeSum++ + } + } + + private drawString( + stdout: SimpleStream, + cols: number, + lines: number, + y: number, + x: number, + text: string, + ) { + if (y < 0 || y >= lines) + return + + let cursorX = x + let out = text + + if (cursorX < 0) { + out = out.slice(-cursorX) + cursorX = 0 + } + + if (cursorX >= cols || out.length === 0) + return + + if (cursorX + out.length > cols) + out = out.slice(0, cols - cursorX) + + stdout.emit(`\x1b[${y + 1};${cursorX + 1}H${out}`) + } + + private resetSmoke() { + this.smokes = [] + this.smokeSum = 0 } - /** - * Utility method to pause execution to pace the animation frames. - */ private sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } + + private static readonly LOGO: string[][] = [ + [ + ' ++ +------ ', + ' || |+-+ | ', + ' /---------|| | | ', + ' + ======== +-+ | ', + ' _|--O========O~\\-+ ', + '//// \\_/ \\_/ ', + ' ', + ], + [ + ' ++ +------ ', + ' || |+-+ | ', + ' /---------|| | | ', + ' + ======== +-+ | ', + ' _|--/O========O\\-+ ', + '//// \\_/ \\_/ ', + ' ', + ], + [ + ' ++ +------ ', + ' || |+-+ | ', + ' /---------|| | | ', + ' + ======== +-+ | ', + ' _|--/~O========O-+ ', + '//// \\_/ \\_/ ', + ' ', + ], + [ + ' ++ +------ ', + ' || |+-+ | ', + ' /---------|| | | ', + ' + ======== +-+ | ', + ' _|--/~\\------/~\\-+ ', + '//// \\_O========O ', + ' ', + ], + [ + ' ++ +------ ', + ' || |+-+ | ', + ' /---------|| | | ', + ' + ======== +-+ | ', + ' _|--/~\\------/~\\-+ ', + '//// \\O========O/ ', + ' ', + ], + [ + ' ++ +------ ', + ' || |+-+ | ', + ' /---------|| | | ', + ' + ======== +-+ | ', + ' _|--/~\\------/~\\-+ ', + '//// O========O_/ ', + ' ', + ], + ] + + private static readonly LOGO_COAL = [ + '____ ', + '| \\@@@@@@@@@@@ ', + '| \\@@@@@@@@@@@@@_ ', + '| | ', + '|__________________| ', + ' (O) (O) ', + ' ', + ] + + private static readonly LOGO_CAR = [ + '____________________ ', + '| ___ ___ ___ ___ | ', + '| |_| |_| |_| |_| | ', + '|__________________| ', + '|__________________| ', + ' (O) (O) ', + ' ', + ] + + private static readonly D51 = [ + [ + ' ==== ________ ___________ ', + ' _D _| |_______/ \\__I_I_____===__|_________| ', + ' |(_)--- | H\\________/ | | =|___ ___| ', + ' / | | H | | | | ||_| |_|| ', + ' | | | H |__--------------------| [___] | ', + ' | ________|___H__/__|_____/[][]~\\_______| | ', + ' |/ | |-----------I_____I [][] [] D |=======|__ ', + '__/ =| o |=-~~\\ /~~\\ /~~\\ /~~\\ ____Y___________|__ ', + ' |/-=|___|= || || || |_____/~\\___/ ', + ' \\_/ \\O=====O=====O=====O_/ \\_/ ', + ' ', + ], + [ + ' ==== ________ ___________ ', + ' _D _| |_______/ \\__I_I_____===__|_________| ', + ' |(_)--- | H\\________/ | | =|___ ___| ', + ' / | | H | | | | ||_| |_|| ', + ' | | | H |__--------------------| [___] | ', + ' | ________|___H__/__|_____/[][]~\\_______| | ', + ' |/ | |-----------I_____I [][] [] D |=======|__ ', + '__/ =| o |=-~~\\ /~~\\ /~~\\ /~~\\ ____Y___________|__ ', + ' |/-=|___|=O=====O=====O=====O |_____/~\\___/ ', + ' \\_/ \\__/ \\__/ \\__/ \\__/ \\_/ ', + ' ', + ], + [ + ' ==== ________ ___________ ', + ' _D _| |_______/ \\__I_I_____===__|_________| ', + ' |(_)--- | H\\________/ | | =|___ ___| ', + ' / | | H | | | | ||_| |_|| ', + ' | | | H |__--------------------| [___] | ', + ' | ________|___H__/__|_____/[][]~\\_______| | ', + ' |/ | |-----------I_____I [][] [] D |=======|__ ', + '__/ =| o |=-O=====O=====O=====O \\ ____Y___________|__ ', + ' |/-=|___|= || || || |_____/~\\___/ ', + ' \\_/ \\__/ \\__/ \\__/ \\__/ \\_/ ', + ' ', + ], + [ + ' ==== ________ ___________ ', + ' _D _| |_______/ \\__I_I_____===__|_________| ', + ' |(_)--- | H\\________/ | | =|___ ___| ', + ' / | | H | | | | ||_| |_|| ', + ' | | | H |__--------------------| [___] | ', + ' | ________|___H__/__|_____/[][]~\\_______| | ', + ' |/ | |-----------I_____I [][] [] D |=======|__ ', + '__/ =| o |=-~O=====O=====O=====O\\ ____Y___________|__ ', + ' |/-=|___|= || || || |_____/~\\___/ ', + ' \\_/ \\__/ \\__/ \\__/ \\__/ \\_/ ', + ' ', + ], + [ + ' ==== ________ ___________ ', + ' _D _| |_______/ \\__I_I_____===__|_________| ', + ' |(_)--- | H\\________/ | | =|___ ___| ', + ' / | | H | | | | ||_| |_|| ', + ' | | | H |__--------------------| [___] | ', + ' | ________|___H__/__|_____/[][]~\\_______| | ', + ' |/ | |-----------I_____I [][] [] D |=======|__ ', + '__/ =| o |=-~~\\ /~~\\ /~~\\ /~~\\ ____Y___________|__ ', + ' |/-=|___|= O=====O=====O=====O|_____/~\\___/ ', + ' \\_/ \\__/ \\__/ \\__/ \\__/ \\_/ ', + ' ', + ], + [ + ' ==== ________ ___________ ', + ' _D _| |_______/ \\__I_I_____===__|_________| ', + ' |(_)--- | H\\________/ | | =|___ ___| ', + ' / | | H | | | | ||_| |_|| ', + ' | | | H |__--------------------| [___] | ', + ' | ________|___H__/__|_____/[][]~\\_______| | ', + ' |/ | |-----------I_____I [][] [] D |=======|__ ', + '__/ =| o |=-~~\\ /~~\\ /~~\\ /~~\\ ____Y___________|__ ', + ' |/-=|___|= || || || |_____/~\\___/ ', + ' \\_/ \\_O=====O=====O=====O/ \\_/ ', + ' ', + ], + ] + + private static readonly D51_COAL = [ + ' ', + ' ', + ' _________________ ', + ' _| \\_____A ', + ' =| | ', + ' -| | ', + '__|________________________|_ ', + '|__________________________|_ ', + ' |_D__D__D_| |_D__D__D_| ', + ' \\_/ \\_/ \\_/ \\_/ ', + ' ', + ] + + private static readonly C51 = [ + [ + ' ___ ', + ' _|_|_ _ __ __ ___________', + ' D__/ \\_(_)___| |__H__| |_____I_Ii_()|_________|', + ' | `---\' |:: `--\' H `--\' | |___ ___| ', + ' +|~~~~~~~~++::~~~~~~~H~~+=====+~~~~~~|~~||_| |_|| ', + ' || | :: H +=====+ | |:: ...| ', + '| | _______|_::-----------------[][]-----| | ', + '| /~~ || |-----/~~~~\\ /[I_____I][][] --|||_______|__', + '------\'|oOo|=[]=- || || | ||=======_|__', + '/~\\____|___|/~\\_| O=======O=======O |__|+-/~\\_| ', + '\\_/ \\_/ \\____/ \\____/ \\____/ \\_/ ', + ' ', + ], + [ + ' ___ ', + ' _|_|_ _ __ __ ___________', + ' D__/ \\_(_)___| |__H__| |_____I_Ii_()|_________|', + ' | `---\' |:: `--\' H `--\' | |___ ___| ', + ' +|~~~~~~~~++::~~~~~~~H~~+=====+~~~~~~|~~||_| |_|| ', + ' || | :: H +=====+ | |:: ...| ', + '| | _______|_::-----------------[][]-----| | ', + '| /~~ || |-----/~~~~\\ /[I_____I][][] --|||_______|__', + '------\'|oOo|=[]=- O=======O=======O | ||=======_|__', + '/~\\____|___|/~\\_| || || |__|+-/~\\_| ', + '\\_/ \\_/ \\____/ \\____/ \\____/ \\_/ ', + ' ', + ], + [ + ' ___ ', + ' _|_|_ _ __ __ ___________', + ' D__/ \\_(_)___| |__H__| |_____I_Ii_()|_________|', + ' | `---\' |:: `--\' H `--\' | |___ ___| ', + ' +|~~~~~~~~++::~~~~~~~H~~+=====+~~~~~~|~~||_| |_|| ', + ' || | :: H +=====+ | |:: ...| ', + '| | _______|_::-----------------[][]-----| | ', + '| /~~ || |-----/~~~~\\ /[I_____I][][] --|||_______|__', + '------\'|oOo|==[]=- O=======O=======O | ||=======_|__', + '/~\\____|___|/~\\_| || || |__|+-/~\\_| ', + '\\_/ \\_/ \\____/ \\____/ \\____/ \\_/ ', + ' ', + ], + [ + ' ___ ', + ' _|_|_ _ __ __ ___________', + ' D__/ \\_(_)___| |__H__| |_____I_Ii_()|_________|', + ' | `---\' |:: `--\' H `--\' | |___ ___| ', + ' +|~~~~~~~~++::~~~~~~~H~~+=====+~~~~~~|~~||_| |_|| ', + ' || | :: H +=====+ | |:: ...| ', + '| | _______|_::-----------------[][]-----| | ', + '| /~~ || |-----/~~~~\\ /[I_____I][][] --|||_______|__', + '------\'|oOo|===[]=- O=======O=======O | ||=======_|__', + '/~\\____|___|/~\\_| || || |__|+-/~\\_| ', + '\\_/ \\_/ \\____/ \\____/ \\____/ \\_/ ', + ' ', + ], + [ + ' ___ ', + ' _|_|_ _ __ __ ___________', + ' D__/ \\_(_)___| |__H__| |_____I_Ii_()|_________|', + ' | `---\' |:: `--\' H `--\' | |___ ___| ', + ' +|~~~~~~~~++::~~~~~~~H~~+=====+~~~~~~|~~||_| |_|| ', + ' || | :: H +=====+ | |:: ...| ', + '| | _______|_::-----------------[][]-----| | ', + '| /~~ || |-----/~~~~\\ /[I_____I][][] --|||_______|__', + '------\'|oOo|===[]=- || || | ||=======_|__', + '/~\\____|___|/~\\_| O=======O=======O |__|+-/~\\_| ', + '\\_/ \\_/ \\____/ \\____/ \\____/ \\_/ ', + ' ', + ], + [ + ' ___ ', + ' _|_|_ _ __ __ ___________', + ' D__/ \\_(_)___| |__H__| |_____I_Ii_()|_________|', + ' | `---\' |:: `--\' H `--\' | |___ ___| ', + ' +|~~~~~~~~++::~~~~~~~H~~+=====+~~~~~~|~~||_| |_|| ', + ' || | :: H +=====+ | |:: ...| ', + '| | _______|_::-----------------[][]-----| | ', + '| /~~ || |-----/~~~~\\ /[I_____I][][] --|||_______|__', + '------\'|oOo|==[]=- || || | ||=======_|__', + '/~\\____|___|/~\\_| O=======O=======O |__|+-/~\\_| ', + '\\_/ \\_/ \\____/ \\____/ \\____/ \\_/ ', + ' ', + ], + ] + + private static readonly C51_COAL = [ + ' ', + ' ', + ' ', + ' _________________ ', + ' _| \\_____A ', + ' =| | ', + ' -| | ', + '__|________________________|_ ', + '|__________________________|_ ', + ' |_D__D__D_| |_D__D__D_| ', + ' \\_/ \\_/ \\_/ \\_/ ', + ' ', + ] + + private static readonly SMOKE = [ + [ + '( )', + '( )', + '( )', + '( )', + '( )', + '( )', + '( )', + '( )', + '()', + '()', + 'O', + 'O', + 'O', + 'O', + 'O', + ' ', + ], + [ + '(@@@)', + '(@@@@)', + '(@@@@)', + '(@@@)', + '(@@)', + '(@@)', + '(@)', + '(@)', + '@@', + '@@', + '@', + '@', + '@', + '@', + '@', + ' ', + ], + ] + + private static readonly SMOKE_ERASER = [ + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ] + + private static readonly SMOKE_DY = [2, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + private static readonly SMOKE_DX = [-2, -1, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3] } diff --git a/src/shell/wush/Wush.ts b/src/shell/wush/Wush.ts index b32b20f..f0b3933 100644 --- a/src/shell/wush/Wush.ts +++ b/src/shell/wush/Wush.ts @@ -8,6 +8,7 @@ import { Lsprg } from '../../program/Lsprg' import { Info } from '../../program/Info' import { Program } from '../../program/Program' import { Terminal } from '../../terminal/Terminal' +import type { CursorPosition } from '../../terminal/CursorProperties' import { EventBroadcaster } from '../../utils/EventBroadcaster' import { SimpleStream } from '../../utils/SimpleStream' import { Shell } from '../Shell' @@ -34,9 +35,11 @@ export class Wush extends Shell { // buffer private buffer: string[] = [] private bufferPos: number = 0 + private promptStart: CursorPosition = { col: 0, row: 0 } private history: string[] = [] - private historyPos: number = 0 + private historyIndex: number | null = null + private historyDraft: string = '' // exec stuff /** @@ -131,6 +134,11 @@ export class Wush extends Shell { Prompt() { this.terminal.Write(`user in ${this.workingDirectory.GetPath()} -> `) + this.promptStart = this.terminal.GetCursorPosition() + this.buffer = [] + this.bufferPos = 0 + this.historyIndex = null + this.historyDraft = '' } /** @@ -150,11 +158,41 @@ export class Wush extends Shell { } WriteEscapedString(data: string) { - let buf = data.split('') - buf.forEach((char) => { - if (!this.ProcessSimpleControlCode(char)) - this.terminal.Write(char) - }) + let i = 0 + let buffer = '' + + const flush = () => { + if (buffer.length === 0) + return + + this.terminal.Write(buffer) + buffer = '' + } + + while (i < data.length) { + const char = data[i] + + if (char === '\x1b') { + flush() + const nextIndex = this.ProcessEscapeSequence(data, i) + if (nextIndex !== null) { + i = nextIndex + continue + } + } + + if (char === '\f' || char === '\n') { + flush() + this.ProcessSimpleControlCode(char) + i++ + continue + } + + buffer += char + i++ + } + + flush() } PushToBuffer(text: string) { @@ -458,13 +496,50 @@ export class Wush extends Shell { } } - // todo: actual processing - ProcessAdvancedControlCode(complex: string): boolean { - if (!complex.match(/\\(.*;)/gm)) - return false + ProcessEscapeSequence(data: string, index: number): number | null { + if (data[index] !== '\x1b' || data[index + 1] !== '[') + return null - return true - // const code = matches[] + let i = index + 2 + let params = '' + + while (i < data.length) { + const char = data[i] + + if ((char >= '0' && char <= '9') || char === ';') { + params += char + i++ + continue + } + + this.ProcessCsiControlCode(char, params) + return i + 1 + } + + return data.length + } + + ProcessCsiControlCode(command: string, params: string): boolean { + const values = params.length === 0 + ? [] + : params.split(';').map(value => Number.parseInt(value, 10)) + + switch (command) { + case 'H': + case 'f': { + const row = Number.isFinite(values[0]) ? values[0] : 1 + const col = Number.isFinite(values[1]) ? values[1] : 1 + + this.terminal.SetCursorPosition(Math.max(col - 1, 0), Math.max(row - 1, 0)) + return true + } + case 'J': { + this.terminal.NewPage() + return true + } + default: + return false + } } HandleKeyInput(key: string, isCharacter: boolean) { @@ -473,57 +548,43 @@ export class Wush extends Shell { switch (key) { case 'ArrowLeft': if (this.bufferPos > 0) { - this.terminal.MoveCursor(-1, 0) - this.MoveBufferPos(-1) + this.bufferPos -= 1 + this.SyncCursorToBuffer() } break case 'ArrowRight': if (this.bufferPos < this.buffer.length) { - this.terminal.MoveCursor(1, 0) - this.MoveBufferPos(1) + this.bufferPos += 1 + this.SyncCursorToBuffer() } break case 'ArrowUp': - if (this.historyPos < this.history.length) { - if (this.historyPos === 0) - this.history.splice(0, 0, this.buffer.join('')) - - this.historyPos++ - console.log(this.historyPos) - console.log(this.history[this.historyPos]) - } + this.NavigateHistory(-1) break case 'ArrowDown': - if (this.historyPos > 0) { - this.historyPos-- - - if (this.historyPos === 0) - this.history.splice(0, 1) - - console.log(this.historyPos) - console.log(this.history[this.historyPos]) - } + this.NavigateHistory(1) break case 'Backspace': // don't erase anything if there's nothing left in the buffer if (this.bufferPos === 0) break + this.ExitHistoryNavigation() this.terminal.RemoveCell() this.RemoveCharFromBuffer(1, this.bufferPos) - // this.buffer.splice(this.bufferPos, 1) - this.terminal.MoveCursor(-1, 0) + this.SyncCursorToBuffer() break case 'Delete': if (this.bufferPos >= this.buffer.length || this.buffer.length <= 0) break - this.bufferPos++ - this.terminal.MoveCursor(1, 0) + this.ExitHistoryNavigation() + this.bufferPos += 1 + this.SyncCursorToBuffer() this.terminal.RemoveCell() this.RemoveCharFromBuffer(1, this.bufferPos) - this.terminal.MoveCursor(-1, 0) + this.SyncCursorToBuffer() break case 'Enter': // send the buffer to stdin if an exec is running @@ -533,7 +594,12 @@ export class Wush extends Shell { } else { // "execute" the buffer this.terminal.MoveCursor(0, 1, { x: true, y: false }) - this.history.splice(0, 0, this.buffer.join('')) + const command = this.buffer.join('') + if (command.length > 0) + this.history.push(command) + + this.historyIndex = null + this.historyDraft = '' this.ExecuteBuffer() } @@ -543,12 +609,99 @@ export class Wush extends Shell { break } } else { - this.historyPos = 0 - + this.ExitHistoryNavigation() // push the character into the buffer - this.terminal.InsertCell(key) - this.PushToBuffer(key) - this.terminal.MoveCursor(1, 0) + this.InsertText(key) } } + + private GetWrapWidth(): number { + const width = Math.floor(this.terminal.GetWidthCells()) + if (!Number.isFinite(width) || width <= 0) + return 1 + + return Math.max(1, width) + } + + private GetCursorPositionForBufferPos(pos: number): CursorPosition { + const width = this.GetWrapWidth() + const absoluteIndex = this.promptStart.col + Math.max(pos, 0) + const rowOffset = Math.floor(absoluteIndex / width) + const col = absoluteIndex % width + + return { row: this.promptStart.row + rowOffset, col } + } + + private SyncCursorToBuffer() { + const position = this.GetCursorPositionForBufferPos(this.bufferPos) + this.terminal.SetCursorPosition(position.col, position.row) + } + + private ClearBuffer() { + if (this.buffer.length === 0) + return + + if (this.bufferPos < this.buffer.length) { + this.bufferPos = this.buffer.length + this.SyncCursorToBuffer() + } + + while (this.bufferPos > 0) { + this.terminal.RemoveCell() + this.RemoveCharFromBuffer(1, this.bufferPos) + this.SyncCursorToBuffer() + } + } + + private InsertText(text: string) { + if (text.length === 0) + return + + for (const char of text) { + this.terminal.InsertCell(char) + this.PushToBuffer(char) + this.SyncCursorToBuffer() + } + } + + private SetBuffer(text: string) { + this.ClearBuffer() + this.InsertText(text) + } + + private ExitHistoryNavigation() { + this.historyIndex = null + this.historyDraft = '' + } + + private NavigateHistory(direction: -1 | 1) { + if (this.history.length === 0) + return + + if (direction === -1) { + if (this.historyIndex === null) { + this.historyDraft = this.buffer.join('') + this.historyIndex = this.history.length - 1 + } else if (this.historyIndex > 0) { + this.historyIndex -= 1 + } else { + return + } + + this.SetBuffer(this.history[this.historyIndex]) + return + } + + if (this.historyIndex === null) + return + + if (this.historyIndex < this.history.length - 1) { + this.historyIndex += 1 + this.SetBuffer(this.history[this.historyIndex]) + return + } + + this.historyIndex = null + this.SetBuffer(this.historyDraft) + } } diff --git a/src/styles/terminal.scss b/src/styles/terminal.scss index 77c4ad3..f01da6b 100644 --- a/src/styles/terminal.scss +++ b/src/styles/terminal.scss @@ -6,6 +6,8 @@ background: colors.$terminal-background; color: colors.$terminal-white; font-family: "CaskaydiaCove", "CaskaydiaCove Nerd Font", "JetBrains Mono", "JetBrains Mono Nerd", monospace; + font-variant-ligatures: normal; + font-feature-settings: "liga" 1, "calt" 1; ::-moz-selection { background: colors.$terminal-white; @@ -15,16 +17,10 @@ p { margin: 0; height: fit-content; - word-wrap: break-word; - text-wrap: nowrap; - display: flex; - - span { - display: inline-block; - } } .line { - width: fit-content + width: fit-content; + white-space: pre; } } diff --git a/src/terminal/Terminal.ts b/src/terminal/Terminal.ts index 00a27fe..465949c 100644 --- a/src/terminal/Terminal.ts +++ b/src/terminal/Terminal.ts @@ -12,6 +12,11 @@ export class Terminal { private cursorStyle: CursorStyle = 'bar' private cellHeight = 0 private cellWidth = 0 + private lines: string[] = [] + private lineElements: HTMLElement[] = [] + private lineWrapped: boolean[] = [] + private scrollPending = false + private cursorRange: Range | null = null private cursorPosition: CursorPosition = { col: 0, @@ -25,9 +30,11 @@ export class Terminal { this.terminal = sqs('#terminal') this.cursor = sqs('#cursor') + this.cursorRange = document.createRange() this.SetCursorStyle('bar') this.NewPage() + this.SetupMetricsRefresh() } async LoadShell(shell: Shell) { @@ -43,98 +50,170 @@ export class Terminal { this.ResetCellSize() this.terminal.innerHTML = '' + this.lines = [] + this.lineElements = [] + this.lineWrapped = [] this.SetCursorPosition(0, 0) } AppendLine() { - const paragraph = document.createElement('p') - paragraph.style.height = `${this.cellHeight}px` - paragraph.id = `line-${this.cursorPosition.row}` - paragraph.className = 'line' - - this.terminal.appendChild(paragraph) - this.UpdateLines() - paragraph.scrollIntoView({behavior: 'smooth'}) - } - - UpdateLines() { - const lines = new Array(...this.terminal.children) - lines.forEach((line, i) => line.id = `line-${i}`) + this.EnsureLine(this.lineElements.length) } /** * @returns index of the last line on the page. -1 if there are no lines */ GetLastLineIndex(): number { - return this.terminal.children.length - 1 + return this.lineElements.length - 1 } Write(text: string) { + if (text.length === 0) + return - text.split('').forEach((char) => { - this.SetCell(char) - this.MoveCursor(1, 0) - }) + const width = this.GetWrapWidth() + let row = this.cursorPosition.row + let col = this.cursorPosition.col + + while (col >= width) { + this.EnsureLine(row) + this.lineWrapped[row] = true + col -= width + row += 1 + } + + this.EnsureLine(row) + + let remaining = text + while (remaining.length > 0) { + let line = this.lines[row] + if (col > line.length) + line += ' '.repeat(col - line.length) + + const available = width - col + if (available <= 0) { + this.lineWrapped[row] = true + row += 1 + col = 0 + this.EnsureLine(row) + continue + } + + const chunk = remaining.slice(0, available) + if (col === line.length) { + line += chunk + } else { + const prefix = line.slice(0, col) + const suffixStart = Math.min(line.length, col + chunk.length) + const suffix = line.slice(suffixStart) + line = prefix + chunk + suffix + } + + this.lines[row] = line + this.UpdateLine(row) + + remaining = remaining.slice(chunk.length) + col += chunk.length + + if (remaining.length > 0) { + this.lineWrapped[row] = true + row += 1 + col = 0 + this.EnsureLine(row) + } else { + this.lineWrapped[row] = false + } + } + + this.cursorPosition.col = Math.max(col, 0) + this.cursorPosition.row = Math.max(row, 0) + this.UpdateCursor() } SetCell(char: string) { - const selector = `#line-${this.cursorPosition.row} .cell-${this.cursorPosition.col}` + const width = this.GetWrapWidth() + let row = this.cursorPosition.row + let col = this.cursorPosition.col - // adjust for the cursor - if (!document.querySelector(`#line-${this.cursorPosition.row}`)) { - for (let i = this.terminal.children.length - 1; i < this.cursorPosition.row; i++) { - this.AppendLine() - } + while (col >= width) { + this.EnsureLine(row) + this.lineWrapped[row] = true + col -= width + row += 1 } - if (!document.querySelector(selector)) { - const line = sqs(`#line-${this.cursorPosition.row}`) + this.EnsureLine(row) - for (let i = line.children.length; i < this.cursorPosition.col + 1; i++) { - const cell = document.createElement('span') - cell.className = `cell-${i}` - cell.style.width = `${this.cellWidth}px` - cell.style.height = `${this.cellHeight}px` + let line = this.lines[row] + if (col > line.length) + line += ' '.repeat(col - line.length) - line.appendChild(cell) - } + if (col === line.length) { + line += char[0] + } else { + line = line.slice(0, col) + char[0] + line.slice(col + 1) } - sqs(selector).innerText = char[0] - } - - UpdateCells() { - const cells = new Array(...sqs(`#line-${this.cursorPosition.row}`).children) - cells.forEach((cell, i) => cell.className = `cell-${i}`) + this.lines[row] = line + this.UpdateLine(row) } InsertCell(char: string) { - const cell = document.createElement('span') - cell.className = `cell-${this.cursorPosition.col}` - cell.style.width = `${this.cellWidth}px` - cell.style.height = `${this.cellHeight}px` + const width = this.GetWrapWidth() + let row = this.cursorPosition.row + let col = this.cursorPosition.col - cell.innerText = char[0] + while (col >= width) { + this.EnsureLine(row) + this.lineWrapped[row] = true + col -= width + row += 1 + } - sqs(`#line-${this.cursorPosition.row} .cell-${this.cursorPosition.col}`).insertAdjacentElement('beforebegin', cell) + this.EnsureLine(row) - this.UpdateCells() + let line = this.lines[row] + if (col > line.length) + line += ' '.repeat(col - line.length) + + line = line.slice(0, col) + char[0] + line.slice(col) + this.lines[row] = line + this.UpdateLine(row) + this.ReflowFromRow(row) } RemoveCell() { - try { - sqs(`#line-${this.cursorPosition.row} .cell-${this.cursorPosition.col - 1}`).remove() - } catch (_) { + let row = this.cursorPosition.row + let removeIndex = this.cursorPosition.col - 1 - } finally { - this.UpdateCells() + if (removeIndex < 0) { + if (row <= 0 || !this.lineWrapped[row - 1]) + return + + row -= 1 + removeIndex = (this.lines[row] ?? '').length - 1 + if (removeIndex < 0) + return } + + this.EnsureLine(row) + let line = this.lines[row] + if (removeIndex >= line.length) + return + + line = line.slice(0, removeIndex) + line.slice(removeIndex + 1) + this.lines[row] = line + this.UpdateLine(row) + this.ReflowFromRow(row) } ResetCellSize() { // dynamically determine cell size using dom const cell = document.createElement('span') cell.textContent = 'A' + cell.style.position = 'absolute' + cell.style.visibility = 'hidden' + cell.style.whiteSpace = 'pre' this.terminal.appendChild(cell) @@ -145,42 +224,78 @@ export class Terminal { } GetHeightCells(): number { - return this.terminal.clientHeight / this.cellHeight + const rect = this.terminal.getBoundingClientRect() + const height = Math.max(0, document.documentElement.clientHeight - rect.top) + return height / this.cellHeight } GetWidthCells(): number { - return this.terminal.clientWidth / this.cellWidth + const rect = this.terminal.getBoundingClientRect() + const width = Math.max(0, document.documentElement.clientWidth - rect.left) + return width / this.cellWidth + } + + private GetWrapWidth(): number { + const width = this.GetWidthCells() + if (!Number.isFinite(width) || width <= 0) + return 1 + + return Math.max(1, Math.floor(width)) } UpdateCursor() { this.SetCursorStyle(this.cursorStyle) - this.cursor.style.left = `${this.cursorPosition.col * this.cellWidth + this.terminal.offsetLeft}px ` - this.cursor.style.top = `${this.cursorPosition.row * this.cellHeight + this.terminal.offsetTop}px` + const rect = this.terminal.getBoundingClientRect() + const left = this.GetCursorXOffset() + rect.left + window.scrollX + const top = this.cursorPosition.row * this.cellHeight + rect.top + window.scrollY + + this.cursor.style.left = `${left}px` + this.cursor.style.top = `${top}px` } GetCursorPosition(): CursorPosition { - return this.cursorPosition + return { col: this.cursorPosition.col, row: this.cursorPosition.row } } SetCursorPosition(col: number, row: number) { - this.cursorPosition.col = Math.max(col, 0) + const width = this.GetWrapWidth() + const clampedCol = Number.isFinite(width) ? Math.min(col, width - 1) : col + + this.cursorPosition.col = Math.max(clampedCol, 0) this.cursorPosition.row = Math.max(row, 0) - try { - sqs(`#line-${this.cursorPosition.row} .cell-${this.cursorPosition.col}`) - } catch (_) { - this.SetCell(' ') - } finally { - this.UpdateCursor() - } + this.EnsureLine(this.cursorPosition.row) + this.UpdateCursor() } MoveCursor(col: number, row: number, absolute: { x: boolean; y: boolean } = { x: false, y: false }) { - this.SetCursorPosition( - !absolute.x ? this.cursorPosition.col + col : col, - !absolute.y ? this.cursorPosition.row + row : row, - ) + const width = this.GetWrapWidth() + let targetCol = !absolute.x ? this.cursorPosition.col + col : col + let targetRow = !absolute.y ? this.cursorPosition.row + row : row + + if (!absolute.x && col !== 0) { + if (targetCol >= width) { + const shift = Math.floor(targetCol / width) + targetRow += shift + targetCol = targetCol % width + } else if (targetCol < 0) { + while (targetCol < 0 && targetRow > 0) { + if (!this.lineWrapped[targetRow - 1]) { + targetCol = 0 + break + } + + targetRow -= 1 + targetCol += width + } + + if (targetCol < 0) + targetCol = 0 + } + } + + this.SetCursorPosition(targetCol, targetRow) } SetCursorStyle(style: CursorStyle) { @@ -190,4 +305,142 @@ export class Terminal { this.cursor.style.height = `${this.cellHeight}px` } } + + private EnsureLine(row: number) { + let added = false + + while (this.lineElements.length <= row) { + const paragraph = document.createElement('p') + paragraph.style.height = `${this.cellHeight}px` + paragraph.id = `line-${this.lineElements.length}` + paragraph.className = 'line' + + this.terminal.appendChild(paragraph) + this.lineElements.push(paragraph) + this.lines.push('') + this.lineWrapped.push(false) + added = true + } + + if (added) + this.ScheduleScroll() + } + + private UpdateLine(row: number) { + const line = this.lineElements[row] + if (line) + line.textContent = this.lines[row] + } + + private ReflowFromRow(startRow: number) { + const width = this.GetWrapWidth() + if (width <= 0) + return + + this.EnsureLine(startRow) + + let text = this.lines[startRow] ?? '' + let endRow = startRow + + while (this.lineWrapped[endRow]) { + endRow += 1 + if (endRow >= this.lines.length) + break + + text += this.lines[endRow] ?? '' + } + + let row = startRow + let index = 0 + + if (text.length === 0) { + this.lines[row] = '' + this.UpdateLine(row) + this.lineWrapped[row] = false + row += 1 + } else { + while (index < text.length) { + const chunk = text.slice(index, index + width) + this.lines[row] = chunk + this.UpdateLine(row) + this.lineWrapped[row] = index + width < text.length + + row += 1 + index += width + + if (index < text.length) + this.EnsureLine(row) + } + } + + for (let i = row; i <= endRow; i++) { + if (this.lines[i] !== '' || this.lineWrapped[i]) { + this.lines[i] = '' + this.UpdateLine(i) + this.lineWrapped[i] = false + } + } + } + + private GetCursorXOffset(): number { + const row = this.cursorPosition.row + const col = this.cursorPosition.col + const line = this.lineElements[row] + const text = this.lines[row] ?? '' + + if (!line) + return col * this.cellWidth + + const textLength = text.length + const clamped = Math.min(col, textLength) + let width = 0 + + const textNode = line.firstChild + if (textNode && textNode.nodeType === Node.TEXT_NODE && clamped > 0) { + if (clamped === textLength) { + width = line.getBoundingClientRect().width + } else if (this.cursorRange) { + this.cursorRange.setStart(textNode, 0) + this.cursorRange.setEnd(textNode, clamped) + width = this.cursorRange.getBoundingClientRect().width + } + } + + if (col > textLength) + width += (col - textLength) * this.cellWidth + + return width + } + + private RefreshMetrics() { + this.ResetCellSize() + this.lineElements.forEach(line => { + line.style.height = `${this.cellHeight}px` + }) + this.UpdateCursor() + } + + private SetupMetricsRefresh() { + if ('fonts' in document) { + document.fonts.ready.then(() => { + this.RefreshMetrics() + }) + } + + window.addEventListener('resize', () => this.RefreshMetrics()) + } + + private ScheduleScroll() { + if (this.scrollPending) + return + + this.scrollPending = true + requestAnimationFrame(() => { + this.scrollPending = false + + const lastLine = this.lineElements[this.lineElements.length - 1] + if (lastLine) + lastLine.scrollIntoView({ block: 'end' }) + }) + } }