| /*
* Lightweight JS Terminal Emulator Core
*/
class TerminalCore {
constructor(cols = 80, rows = 24) {
this.cols = cols;
this.rows = rows;
this.buffer = []; // Array of { char: ' ', fg: null, bg: null, bold: false }
this.scrollback = []; // Array of pushed-out rows
this.maxScrollback = 10000;
this.scrollOffset = 0; // Number of lines scrolled back (0 = bottom)
this.cx = 0;
this.cy = 0;
this.defaultCell = { char: ' ', fg: null, bg: null, bold: false };
this.currentStyle = { fg: null, bg: null, bold: false };
this.initBuffer();
}
initBuffer() {
this.wrapNext = false;
this.buffer = [];
for (let r = 0; r < this.rows; r++) {
let row = [];
for (let c = 0; c < this.cols; c++) {
row.push({...this.defaultCell});
}
this.buffer.push(row);
}
}
resize(cols, rows) {
this.wrapNext = false;
let newBuffer = [];
for (let r = 0; r < rows; r++) {
let row = [];
for (let c = 0; c < cols; c++) {
if (r < this.rows && c < this.cols) {
row.push(this.buffer[r][c]);
} else {
row.push({...this.defaultCell});
}
}
newBuffer.push(row);
}
this.buffer = newBuffer;
this.cols = cols;
this.rows = rows;
if (this.cx >= cols) this.cx = cols - 1;
if (this.cy >= rows) this.cy = rows - 1;
}
write(data) {
if (this.scrollOffset > 0 && data.length > 0) {
this.scrollOffset = 0;
}
for (let i = 0; i < data.length; i++) {
let char = data[i];
if (char === '\n') {
this.cy++;
this.cx = 0;
this.wrapNext = false;
if (this.cy >= this.rows) {
this.scrollUp();
this.cy = this.rows - 1;
}
} else if (char === '\r') {
this.cx = 0;
this.wrapNext = false;
} else if (char === '\b') {
if (this.cx > 0) this.cx--;
this.wrapNext = false;
} else if (char === '\t') {
this.cx = (this.cx + 8) - (this.cx % 8);
if (this.cx >= this.cols) {
this.cx = this.cols - 1;
this.wrapNext = true;
} else {
this.wrapNext = false;
}
} else if (char === '\x07') {
// Bell
} else if (char === '\x1b') { // ESC
if (i + 1 < data.length && data[i + 1] === '[') {
// CSI
let j = i + 2;
let paramStr = "";
while (j < data.length && (data[j] >= '0' && data[j] <= '9' || data[j] === ';' || data[j] === '?')) {
paramStr += data[j];
j++;
}
if (j < data.length) {
let cmd = data[j];
this.handleCSI(cmd, paramStr);
i = j;
}
} else if (i + 1 < data.length && data[i+1] === ']') {
// OSC
let j = i + 2;
while (j < data.length && data[j] !== '\x07' && data[j] !== '\x1b') j++;
if (j < data.length && data[j] === '\x1b' && j + 1 < data.length && data[j+1] === '\\') j++;
i = j;
} else if (i + 1 < data.length && (data[i+1] === '(' || data[i+1] === ')' || data[i+1] === '*' || data[i+1] === '+')) {
// Designate G0-G3 Character Set (3 bytes)
i += 2;
} else {
i++; // Skip unknown 2-byte ESC sequence
}
} else {
if (this.wrapNext) {
this.cx = 0;
this.cy++;
this.wrapNext = false;
if (this.cy >= this.rows) {
this.scrollUp();
this.cy = this.rows - 1;
}
}
if (this.cy >= this.rows) this.cy = this.rows - 1;
this.buffer[this.cy][this.cx] = {
char: char,
fg: this.currentStyle.fg,
bg: this.currentStyle.bg,
bold: this.currentStyle.bold
};
if (this.cx < this.cols - 1) {
this.cx++;
} else {
this.wrapNext = true;
}
}
}
}
handleCSI(cmd, paramStr) {
if (paramStr.startsWith('?')) paramStr = paramStr.substring(1);
let params = paramStr.split(';').map(p => parseInt(p, 10));
if (cmd !== 'm') {
this.wrapNext = false;
}
switch (cmd) {
case 'A': // Cursor Up
this.cy = Math.max(0, this.cy - (params[0] || 1)); break;
case 'B': // Cursor Down
this.cy = Math.min(this.rows - 1, this.cy + (params[0] || 1)); break;
case 'C': // Cursor Forward
this.cx = Math.min(this.cols - 1, this.cx + (params[0] || 1)); break;
case 'D': // Cursor Back
this.cx = Math.max(0, this.cx - (params[0] || 1)); break;
case 'G': // Cursor Horizontal Absolute
case '`':
this.cx = Math.min(this.cols - 1, Math.max(0, (params[0] || 1) - 1)); break;
case 'd': // Vertical Position Absolute
this.cy = Math.min(this.rows - 1, Math.max(0, (params[0] || 1) - 1)); break;
case 'H':
case 'f': // Cursor Position
this.cy = Math.min(this.rows - 1, Math.max(0, (params[0] || 1) - 1));
this.cx = Math.min(this.cols - 1, Math.max(0, (params[1] || 1) - 1));
break;
case 'J': // Erase in Display
let modeJ = params[0] || 0;
if (modeJ === 0) {
for (let c = this.cx; c < this.cols; c++) this.buffer[this.cy][c] = {...this.defaultCell};
for (let r = this.cy + 1; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) this.buffer[r][c] = {...this.defaultCell};
}
} else if (modeJ === 1) {
for (let c = 0; c <= this.cx; c++) this.buffer[this.cy][c] = {...this.defaultCell};
for (let r = 0; r < this.cy; r++) {
for (let c = 0; c < this.cols; c++) this.buffer[r][c] = {...this.defaultCell};
}
} else if (modeJ === 2) {
this.initBuffer();
}
break;
case 'K': // Erase in Line
let modeK = params[0] || 0;
if (modeK === 0) {
for (let c = this.cx; c < this.cols; c++) this.buffer[this.cy][c] = {...this.defaultCell};
} else if (modeK === 1) {
for (let c = 0; c <= this.cx; c++) this.buffer[this.cy][c] = {...this.defaultCell};
} else if (modeK === 2) {
for (let c = 0; c < this.cols; c++) this.buffer[this.cy][c] = {...this.defaultCell};
}
break;
case '@': // Insert Character
let nInsert = params[0] || 1;
for (let c = this.cols - 1; c >= this.cx + nInsert; c--) {
this.buffer[this.cy][c] = {...this.buffer[this.cy][c - nInsert]};
}
for (let c = this.cx; c < this.cx + nInsert && c < this.cols; c++) {
this.buffer[this.cy][c] = {...this.defaultCell};
}
break;
case 'P': // Delete Character
let nDelete = params[0] || 1;
if (nDelete > this.cols - this.cx) nDelete = this.cols - this.cx;
for (let c = this.cx; c < this.cols - nDelete; c++) {
this.buffer[this.cy][c] = {...this.buffer[this.cy][c + nDelete]};
}
for (let c = Math.max(this.cx, this.cols - nDelete); c < this.cols; c++) {
this.buffer[this.cy][c] = {...this.defaultCell};
}
break;
case 'X': // Erase Character
let nErase = params[0] || 1;
for (let c = this.cx; c < this.cx + nErase && c < this.cols; c++) {
this.buffer[this.cy][c] = {...this.defaultCell};
}
break;
case 'm': // SGR
for (let i = 0; i < params.length; i++) {
let code = params[i] || 0;
if (code === 0) this.currentStyle = { fg: null, bg: null, bold: false };
else if (code === 1) this.currentStyle.bold = true;
else if (code >= 30 && code <= 37) this.currentStyle.fg = code - 30;
else if (code >= 40 && code <= 47) this.currentStyle.bg = code - 40;
}
break;
}
}
scrollUp() {
let shiftedRow = this.buffer.shift();
this.scrollback.push(shiftedRow);
if (this.scrollback.length > this.maxScrollback) {
this.scrollback.shift();
}
let row = [];
for (let c = 0; c < this.cols; c++) row.push({...this.defaultCell});
this.buffer.push(row);
}
scroll(amount) {
this.scrollOffset += amount;
if (this.scrollOffset < 0) this.scrollOffset = 0;
if (this.scrollOffset > this.scrollback.length) this.scrollOffset = this.scrollback.length;
}
renderHtml() {
let html = '';
let lastStyle = null;
const colorMap = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'];
let viewBuffer = [];
if (this.scrollOffset > 0) {
let startIdx = this.scrollback.length - this.scrollOffset;
for (let i = 0; i < this.rows; i++) {
let idx = startIdx + i;
if (idx < this.scrollback.length) {
viewBuffer.push(this.scrollback[idx]);
} else {
viewBuffer.push(this.buffer[idx - this.scrollback.length]);
}
}
} else {
viewBuffer = this.buffer;
}
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
let cell = viewBuffer[r][c];
let isCursor = (this.scrollOffset === 0 && r === this.cy && c === this.cx);
let styleKey = `${cell.fg}-${cell.bg}-${cell.bold}-${isCursor}`;
if (styleKey !== lastStyle) {
if (lastStyle !== null) html += '</span>';
let classes = [];
if (cell.fg !== null) classes.push(`ansi-fg-${colorMap[cell.fg]}`);
if (cell.bg !== null) classes.push(`ansi-bg-${colorMap[cell.bg]}`);
if (cell.bold) classes.push(`ansi-bold`);
if (isCursor) classes.push(`term-cursor`);
if (classes.length > 0) {
html += `<span class="${classes.join(' ')}">`;
} else {
html += `<span>`;
}
lastStyle = styleKey;
}
html += (cell.char === ' ' || cell.char === '') ? ' ' : this.escapeHtml(cell.char);
}
if (r < this.rows - 1) html += '<br>';
}
if (lastStyle !== null) html += '</span>';
return html;
}
escapeHtml(s) {
return s.replace(/[&<>"']/g, function(m) {
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m];
});
}
}
|