diff --git a/src/scripts/terminal.ts b/src/scripts/terminal.ts index c9cc2e4..3aadd67 100644 --- a/src/scripts/terminal.ts +++ b/src/scripts/terminal.ts @@ -1,5 +1,5 @@ // The real terminal: xterm.js connected to the shared shell server over SSH. Loaded only once the first command is sent. -import { Terminal, type IMarker } from '@xterm/xterm'; +import { Terminal, type IBufferLine, type IMarker } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import css from '@xterm/xterm/css/xterm.css?inline'; import { connect } from './ssh'; @@ -7,6 +7,10 @@ import { connect } from './ssh'; const SERVER = 'wss://web.nowhere.wirs.ing'; const HOST_KEY = 'SHA256:aLNZKHCfhvbGYnjghKsOLPrOWPVWwCsIPCuTb5f9J6A'; const SCROLLBACK = 10000; +// xterm.js's default colors, to color lines moved into the page like the terminal does. +const ANSI = ['#2e3436', '#cc0000', '#4e9a06', '#c4a000', '#3465a4', '#75507b', '#06989a', '#d3d7cf', + '#555753', '#ef2929', '#8ae234', '#fce94f', '#729fcf', '#ad7fa8', '#34e2e2', '#eeeeec']; +const CUBE = [0, 95, 135, 175, 215, 255]; const boringData = 'LS0tLS1PUlRWQSBCQ1JBRkZVIENFVklOR1IgWFJMLS0tLS0KbzNPeW9hQW1uUDFlTUt4Z3F3Uk5OTk5OT1Q1aW96SE5OTk5Sb3o5aE1ETk5OTk5OTk5OT05OTk5aak5OTk5nbXAydGdNSgpEbEFHSGtCRE5OTlBQRDhVdU8vVDRKRE8xSnRVYUxIMUQyOWIvNlFPVzk3NWlyOERmTmZDRmppRE5OTlh3eVh2UmU1RmJ1ClhqTk5OTmdtcDJ0Z01KRGxBR0hrQkROTk5QUEQ4VXVPL1Q0SkRPMUp0VWFMSDFEMjliLzZRT1c5NzVpcjhEZk5mQ0ZqaUQKTk5OUk44eXRHaFIrMTlMTkVoeHo3cmQyU00xQ2lIb1IrYmpsQk5SQjVWb1BhaWFXUWpyUlU4b3VNTlVJbk5ycXVHSVFvMgp3L2JaUmEzaXo5N2tQalBqOVlQOU5OTk5WS1dpbzNFTm96OTNuVElsTUY1M25LV21ZenloTWxPd3JKcTNuSjRnWndWbFp0ClJQTmpEPQotLS0tLVJBUSBCQ1JBRkZVIENFVklOR1IgWFJMLS0tLS0K'; /** Continues the page's `prompt` with a terminal in which `command` was just entered, and returns that terminal. */ @@ -47,7 +51,7 @@ export function start(prompt: HTMLElement, command: string) { used = buffer.baseY ? term.rows : Math.max(used, buffer.cursorY + 1); const last = moved && !moved.isDisposed ? moved.line : -1; for (let y = last; y >= buffer.baseY; y--) history.lastChild!.remove(); // pulled back onto a taller screen - for (let y = last + 1; y < buffer.baseY; y++) history.append(`${buffer.getLine(y)!.translateToString(true)}\n`); + for (let y = last + 1; y < buffer.baseY; y++) history.append(pageLine(buffer.getLine(y)!)); while (history.childNodes.length > SCROLLBACK) history.firstChild!.remove(); if (last !== buffer.baseY - 1) { moved?.dispose(); @@ -57,6 +61,49 @@ export function start(prompt: HTMLElement, command: string) { history.style.lineHeight = `${rowHeight}px`; screen.style.height = `${(buffer.type === 'normal' ? used : term.rows) * rowHeight}px`; } + + // A line as page text, with the colors and styles the terminal shows it in. + const palette = ANSI.map((c, i) => (i === 2 ? color : c)); // green is the page's, as in the theme + const colorOf = (n: number, rgb: boolean) => { + if (rgb) return `#${n.toString(16).padStart(6, '0')}`; + if (n < 16) return palette[n]; + if (n < 232) return `rgb(${CUBE[Math.floor((n - 16) / 36)]},${CUBE[Math.floor((n - 16) / 6) % 6]},${CUBE[(n - 16) % 6]})`; + const gray = 8 + (n - 232) * 10; + return `rgb(${gray},${gray},${gray})`; + }; + const cell = term.buffer.normal.getNullCell(); + function pageLine(line: IBufferLine) { + const row = document.createElement('div'); + let end = line.length; // up to the last visible cell, so copied text has no trailing spaces + while (end && !line.getCell(end - 1, cell)!.getChars().trim() && cell.isBgDefault() && !cell.isInverse()) end--; + let span: HTMLElement | undefined; + let current: string | undefined; + for (let x = 0; x < end; x++) { + line.getCell(x, cell); + if (!cell.getWidth()) continue; // the second half of a wide character + const fgIndex = cell.getFgColor() + (cell.isBold() && cell.isFgPalette() && cell.getFgColor() < 8 ? 8 : 0); // bold is bright + let fg = cell.isFgDefault() ? '' : colorOf(fgIndex, cell.isFgRGB()); + let bg = cell.isBgDefault() ? '' : colorOf(cell.getBgColor(), cell.isBgRGB()); + if (cell.isInverse()) [fg, bg] = [bg || '#000', fg || color]; + if (cell.isDim()) fg = `color-mix(in srgb, ${fg || color} 50%, transparent)`; + const decoration = [cell.isUnderline() && 'underline', cell.isStrikethrough() && 'line-through'].filter(Boolean).join(' '); + const style = [ + fg && `color:${fg}`, + bg && `background:${bg}`, + cell.isBold() && 'font-weight:bold', + cell.isItalic() && 'font-style:italic', + decoration && `text-decoration:${decoration}`, + cell.isInvisible() && 'visibility:hidden', + ].filter(Boolean).join(';'); + if (style !== current) { + span = row.appendChild(document.createElement('span')); + span.style.cssText = current = style; + } + span!.textContent += cell.getChars() || ' '; + } + return row; + } + // "Erase scrollback" (sent by `clear`) erases it from the page too. term.parser.registerCsiHandler({ final: 'J' }, (params) => (params[0] === 3 && history.replaceChildren(), false)); diff --git a/src/styles/style.css b/src/styles/style.css index ec4fc30..795a7d5 100644 --- a/src/styles/style.css +++ b/src/styles/style.css @@ -71,6 +71,7 @@ a:hover, a:focus-visible { the screen shows the rows in use and xterm.js fits rows and columns to the window. */ .prompt:has(+ .history) { margin-bottom: 0; } .history { white-space: pre-wrap; } +.history > div { min-height: 1lh; } /* keeps empty lines */ .screen { overflow: clip; } .screen > div { height: 100dvh; } .screen .xterm-viewport, .screen .scrollbar { display: none; } /* the page scrolls instead */