Six findings. Each terminal-semantics case was checked in a real terminal (tmux, reading back the painted screen) before changing anything: `100%\r\x1b[KOK` shows `OK`. Modelling the `\r` without its erase left the previous frame's tail standing — a regression against the old truncate, since `\r\x1b[K` is the single idiom every spinner and progress bar writes. Erase is now part of the same replay, in all three parameter forms. `a\tb\rXY` shows `XY b`. Counting a tab as one column produced `XYb` and destroyed the alignment this card exists to hold, so the cursor now advances by terminal columns: tabs reach the next 8-column stop and a wide character takes two cells. `\x1b[31mabc\rX\nnext` paints BOTH lines red. A newline does not reset the graphic state, so state threads from one replayed line to the next instead of closing at each line end. Only `m` accumulates into a cell's style now. Folding cursor and erase sequences in grew the state string per redraw and emitted boundaries anser had to discard. The empty check reads the parsed lines the card renders rather than the raw text: output that is only escapes or control bytes survives `trim()` yet parses to nothing, and drew blank rows plus a copy control for invisible bytes instead of the placeholder. The gutter is the card's own left padding rather than a margin. Every render site rewrites `margin` wholesale for its own indent, which silently cancelled the reservation and let a container clip the dot. The fixture sample no longer carries an `[exit code: 1]` line: the real bash presenter consumes that marker precisely because the card shows the exit as its own pill, so the built-bundle snapshot had pinned a frame showing it twice — one the product path cannot produce.
334 lines
14 KiB
TypeScript
334 lines
14 KiB
TypeScript
// parseAnsiLines, the ANSI model behind TerminalBlock: anser's SGR runs
|
|
// resolved into inline styles and folded into per-line span arrays, with every
|
|
// escape and control character that carries no color removed first. The DOM
|
|
// side of the same model (which runs get a span wrapper) is in
|
|
// terminal-block.spec.tsx.
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import { parseAnsiLines } from '../src/ansi.ts'
|
|
|
|
const ESC = '\u001b'
|
|
const BS = '\u0008'
|
|
|
|
/** Paint `text` with the SGR `codes`, then reset. */
|
|
function sgr(codes: string, text: string): string {
|
|
return `${ESC}[${codes}m${text}${ESC}[0m`
|
|
}
|
|
|
|
/** The single span of a single-line, single-run parse. */
|
|
function onlySpan(text: string) {
|
|
const lines = parseAnsiLines(text)
|
|
expect(lines).toHaveLength(1)
|
|
expect(lines[0]).toHaveLength(1)
|
|
return lines[0]![0]!
|
|
}
|
|
|
|
describe('parseAnsiLines: text without SGR state', () => {
|
|
it('leaves plain text as one unstyled span', () => {
|
|
expect(parseAnsiLines('hello')).toEqual([[{ text: 'hello', style: undefined }]])
|
|
})
|
|
|
|
it('returns exactly one empty line for empty input', () => {
|
|
expect(parseAnsiLines('')).toEqual([[]])
|
|
})
|
|
|
|
it('splits a multi-line run and drops the empty line between two blocks', () => {
|
|
expect(parseAnsiLines('a\n\nb')).toEqual([
|
|
[{ text: 'a', style: undefined }],
|
|
[],
|
|
[{ text: 'b', style: undefined }],
|
|
])
|
|
})
|
|
|
|
it('keeps tabs, which the terminal surface needs for column layout', () => {
|
|
expect(onlySpan('a\tb')).toEqual({ text: 'a\tb', style: undefined })
|
|
})
|
|
})
|
|
|
|
describe('parseAnsiLines: basic colors mapped onto theme tokens', () => {
|
|
it.each<[string, string, string]>([
|
|
['30', 'black', 'var(--dsw-alias-label-primary)'],
|
|
['37', 'white', 'var(--dsw-alias-label-primary)'],
|
|
['90', 'bright black', 'var(--dsw-alias-label-tertiary)'],
|
|
['31', 'red', 'var(--dsw-alias-state-error-primary)'],
|
|
['91', 'bright red', 'var(--dsw-alias-state-error-secondary)'],
|
|
['32', 'green', 'var(--dsw-alias-state-success-primary)'],
|
|
['92', 'bright green', 'var(--dsw-alias-state-success-secondary)'],
|
|
['33', 'yellow', 'var(--dsw-alias-state-warn-primary)'],
|
|
['93', 'bright yellow', 'var(--dsw-alias-state-warn-secondary)'],
|
|
['34', 'blue', 'var(--dsw-alias-state-business-primary)'],
|
|
['94', 'bright blue', 'var(--dsw-static-blue-400)'],
|
|
])('SGR %s (%s) resolves to %s', (code, _name, token) => {
|
|
expect(onlySpan(sgr(code, 'x'))).toEqual({ text: 'x', style: { color: token } })
|
|
})
|
|
})
|
|
|
|
describe('parseAnsiLines: colors with no token equivalent', () => {
|
|
it.each<[string, string, string]>([
|
|
['35', 'magenta', 'rgb(187, 0, 187)'],
|
|
['36', 'cyan', 'rgb(0, 187, 187)'],
|
|
['38;5;208', '256-palette orange', 'rgb(255, 135, 0)'],
|
|
['38;2;10;20;30', 'truecolor', 'rgb(10, 20, 30)'],
|
|
])('SGR %s (%s) falls through to %s', (code, _name, literal) => {
|
|
expect(onlySpan(sgr(code, 'x')).style).toEqual({ color: literal })
|
|
})
|
|
})
|
|
|
|
describe('parseAnsiLines: backgrounds', () => {
|
|
it('sets backgroundColor for a background-only run', () => {
|
|
expect(onlySpan(sgr('44', 'x')).style).toEqual({ backgroundColor: 'rgb(0, 0, 187)' })
|
|
})
|
|
|
|
it('keeps the literal foreground when the run paints its own background', () => {
|
|
expect(onlySpan(sgr('41;37', 'x')).style).toEqual({
|
|
backgroundColor: 'rgb(187, 0, 0)',
|
|
color: 'rgb(255,255,255)',
|
|
})
|
|
})
|
|
|
|
it('renders reverse video as the swapped pair anser reports', () => {
|
|
expect(onlySpan(sgr('31;7', 'x')).style).toEqual({
|
|
backgroundColor: 'rgb(187, 0, 0)',
|
|
color: 'rgb(0, 0, 0)',
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('parseAnsiLines: decorations', () => {
|
|
it.each<[string, string, Record<string, unknown>]>([
|
|
['1', 'bold', { fontWeight: 700 }],
|
|
['2', 'dim', { opacity: 0.7 }],
|
|
['3', 'italic', { fontStyle: 'italic' }],
|
|
['4', 'underline', { textDecoration: 'underline' }],
|
|
['9', 'strikethrough', { textDecoration: 'line-through' }],
|
|
['8', 'hidden', { visibility: 'hidden' }],
|
|
])('SGR %s (%s) resolves to %o', (code, _name, style) => {
|
|
expect(onlySpan(sgr(code, 'x')).style).toEqual(style)
|
|
})
|
|
|
|
it('lets the later textDecoration win when a run declares underline and strikethrough', () => {
|
|
expect(onlySpan(sgr('4;9', 'x')).style).toEqual({ textDecoration: 'line-through' })
|
|
expect(onlySpan(sgr('9;4', 'x')).style).toEqual({ textDecoration: 'underline' })
|
|
})
|
|
|
|
it('combines a color with several decorations in one style', () => {
|
|
expect(onlySpan(sgr('1;3;31', 'x')).style).toEqual({
|
|
color: 'var(--dsw-alias-state-error-primary)',
|
|
fontWeight: 700,
|
|
fontStyle: 'italic',
|
|
})
|
|
})
|
|
|
|
it('reproduces no animation for blink, leaving the run unstyled', () => {
|
|
expect(onlySpan(sgr('5', 'x'))).toEqual({ text: 'x', style: undefined })
|
|
})
|
|
})
|
|
|
|
describe('parseAnsiLines: sequences that carry no color', () => {
|
|
it('removes an OSC string with its BEL terminator', () => {
|
|
expect(onlySpan(`a${ESC}]0;window title\u0007b`)).toEqual({ text: 'ab', style: undefined })
|
|
})
|
|
|
|
it('removes an OSC string terminated by ST', () => {
|
|
expect(onlySpan(`a${ESC}]8;;https://example.com${ESC}\\b`)).toEqual({ text: 'ab', style: undefined })
|
|
})
|
|
|
|
it('removes non-CSI escapes such as charset selection and reset', () => {
|
|
expect(onlySpan(`x${ESC}(By${ESC}cz`)).toEqual({ text: 'xyz', style: undefined })
|
|
})
|
|
|
|
it('removes inert C0 controls', () => {
|
|
expect(onlySpan('\u0000ab\u001fc\u007f')).toEqual({ text: 'abc', style: undefined })
|
|
})
|
|
|
|
it('keeps CSI sequences that only move the cursor out of the text', () => {
|
|
expect(onlySpan(`${ESC}[2K${ESC}[1Adone`)).toEqual({ text: 'done', style: undefined })
|
|
})
|
|
})
|
|
|
|
describe('parseAnsiLines: carriage returns', () => {
|
|
it('keeps only the last redraw of a line', () => {
|
|
expect(onlySpan('10%\r55%\r100%')).toEqual({ text: '100%', style: undefined })
|
|
})
|
|
|
|
it('leaves the tail of a longer frame standing under a shorter redraw', () => {
|
|
// Verified against a real terminal: `100%\rOK` paints `OK0%`. A carriage
|
|
// return only moves the cursor, so the two columns the redraw never reaches
|
|
// still hold the frame beneath — truncating to the last `\r` would lose them.
|
|
expect(onlySpan('100%\rOK')).toEqual({ text: 'OK0%', style: undefined })
|
|
expect(onlySpan('abcdef\rXY')).toEqual({ text: 'XYcdef', style: undefined })
|
|
})
|
|
|
|
it('clamps a backspace run at the line start rather than going negative', () => {
|
|
// More backspaces than characters: the cursor stops at column 0, so the
|
|
// following write simply overwrites from there.
|
|
expect(onlySpan(`ab${BS}${BS}${BS}${BS}xyz`)).toEqual({ text: 'xyz', style: undefined })
|
|
})
|
|
|
|
it('keeps SGR state in force across a redraw, as a terminal does', () => {
|
|
// Verified against a real terminal: `\x1b[31mgone\rkept` paints `kept` RED.
|
|
// A carriage return moves the cursor; it does not reset the graphic state,
|
|
// so the redraw inherits the color the discarded frame was written with.
|
|
expect(onlySpan(`${ESC}[31mgone\rkept`))
|
|
.toEqual({ text: 'kept', style: { color: 'var(--dsw-alias-state-error-primary)' } })
|
|
})
|
|
|
|
it('preserves both lines of a CRLF pair instead of treating it as a redraw', () => {
|
|
expect(parseAnsiLines('a\r\r\nb\r\n')).toEqual([
|
|
[{ text: 'a', style: undefined }],
|
|
[{ text: 'b', style: undefined }],
|
|
[],
|
|
])
|
|
})
|
|
|
|
it('applies the redraw per line, not across the whole text', () => {
|
|
expect(parseAnsiLines('one\rtwo\nthree')).toEqual([
|
|
[{ text: 'two', style: undefined }],
|
|
[{ text: 'three', style: undefined }],
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('parseAnsiLines: backspaces', () => {
|
|
it('applies a backspace as the overwrite a terminal draws', () => {
|
|
// `abc` then two backspaces then `XY` shows as `aXY`, not `abcXY`.
|
|
expect(onlySpan(`abc${BS}${BS}XY`)).toEqual({ text: 'aXY', style: undefined })
|
|
})
|
|
|
|
it('stops at the line start instead of eating the newline before it', () => {
|
|
expect(parseAnsiLines(`ab\n${BS}${BS}${BS}cd`)).toEqual([
|
|
[{ text: 'ab', style: undefined }],
|
|
[{ text: 'cd', style: undefined }],
|
|
])
|
|
})
|
|
|
|
it('treats a trailing backspace as a cursor move, not a delete', () => {
|
|
// Verified against a real terminal: `abc\b` still shows `abc`. Only a later
|
|
// write overwrites; a backspace with nothing after it erases nothing.
|
|
expect(onlySpan(`abc${BS}`)).toEqual({ text: 'abc', style: undefined })
|
|
// Same at a line boundary: the newline ends the line before any overwrite.
|
|
expect(parseAnsiLines(`abc${BS}\ndef`)).toEqual([
|
|
[{ text: 'abc', style: undefined }],
|
|
[{ text: 'def', style: undefined }],
|
|
])
|
|
})
|
|
|
|
it('steps over an SGR sequence instead of erasing its bytes', () => {
|
|
// `abc` reset then two backspaces then `XY`: erasing the reset's bytes would
|
|
// corrupt it and repaint the rest of the line with whatever the remainder
|
|
// parses as. The visible result is `aXY`, still red, with the reset intact.
|
|
expect(parseAnsiLines(`${sgr('31', 'abc')}${BS}${BS}XY`)).toEqual([[
|
|
{ text: 'a', style: { color: 'var(--dsw-alias-state-error-primary)' } },
|
|
{ text: 'XY', style: undefined },
|
|
]])
|
|
})
|
|
|
|
it('erases across a style boundary without dropping the styles between', () => {
|
|
// The backspace reaches back past the reset to the last printed character.
|
|
expect(parseAnsiLines(`${sgr('32', 'ok')}${ESC}[31m${BS}bad`)).toEqual([[
|
|
{ text: 'o', style: { color: 'var(--dsw-alias-state-success-primary)' } },
|
|
{ text: 'bad', style: { color: 'var(--dsw-alias-state-error-primary)' } },
|
|
]])
|
|
})
|
|
|
|
it('replays a redraw and a trailing backspace as pure cursor moves', () => {
|
|
// Verified against a real terminal: `old\rnew\b` shows `new`. The redraw
|
|
// repaints all three columns and the trailing backspace only moves the
|
|
// cursor left — nothing overwrites the `w`, so nothing is lost.
|
|
expect(onlySpan(`old\rnew${BS}`)).toEqual({ text: 'new', style: undefined })
|
|
})
|
|
|
|
it('overwrites only the columns the later write reaches, keeping the rest styled', () => {
|
|
// Verified against a real terminal: red `bad`, three backspaces, then `ok`
|
|
// shows `okd` — the cursor returned to column 0 and `ok` overwrote two of
|
|
// the three columns, so the untouched `d` keeps the run's red.
|
|
expect(parseAnsiLines(`${sgr('31', 'bad')}${BS}${BS}${BS}ok`)).toEqual([[
|
|
{ text: 'ok', style: undefined },
|
|
{ text: 'd', style: { color: 'var(--dsw-alias-state-error-primary)' } },
|
|
]])
|
|
})
|
|
})
|
|
|
|
describe('parseAnsiLines: erase and column arithmetic', () => {
|
|
it('erases the rest of the line, the fixed companion of a redraw', () => {
|
|
// Verified in a real terminal: `100%\r\x1b[KOK` shows `OK`. Every spinner and
|
|
// progress bar writes `\r\x1b[K`; without the erase the previous frame's tail
|
|
// stands and the card shows text the terminal never displayed.
|
|
expect(onlySpan(`100%\r${ESC}[KOK`)).toEqual({ text: 'OK', style: undefined })
|
|
// The parameterless form and `0` are the same erase.
|
|
expect(onlySpan(`100%\r${ESC}[0KOK`)).toEqual({ text: 'OK', style: undefined })
|
|
})
|
|
|
|
it('erases the whole line for the 2K form and to the cursor for 1K', () => {
|
|
expect(onlySpan(`ab\r${ESC}[2Kxy`)).toEqual({ text: 'xy', style: undefined })
|
|
// 1K clears left of the cursor without moving it, so those columns read as
|
|
// blanks — verified in a real terminal, which shows ` |` for this input.
|
|
expect(onlySpan(`abcd${ESC}[1K|`)).toEqual({ text: ' |', style: undefined })
|
|
})
|
|
|
|
it('paints columns a 2K dropped as blanks when a later write lands past them', () => {
|
|
// 2K clears the line but leaves the cursor where it was, so writing there
|
|
// leaves the columns before it unwritten — blanks, as a terminal shows.
|
|
expect(onlySpan(`abcd${ESC}[2Kx`)).toEqual({ text: ' x', style: undefined })
|
|
})
|
|
|
|
it('advances a redraw cursor by tab stops, leaving a tabbed column standing', () => {
|
|
// Verified in a real terminal: `a\tb\rXY` shows `XY b` — the `b` sits at
|
|
// column 8, which a two-character redraw cannot reach. Counting the tab as
|
|
// one column would have produced `XYb` and destroyed the alignment.
|
|
expect(onlySpan('a\tb\rXY')).toEqual({ text: 'XY b', style: undefined })
|
|
})
|
|
|
|
it('counts a wide character as the two columns a terminal advances', () => {
|
|
// `中` occupies two cells, so a two-character redraw covers exactly it.
|
|
expect(onlySpan('中x\rab')).toEqual({ text: 'abx', style: undefined })
|
|
})
|
|
|
|
it('does not accumulate a cursor or erase sequence into a cell style', () => {
|
|
// Only SGR carries graphic state. An erase folded into the style string
|
|
// would grow it per redraw and emit boundaries anser has to discard.
|
|
expect(parseAnsiLines(`${ESC}[31ma\r${ESC}[Kb`)).toEqual([[
|
|
{ text: 'b', style: { color: 'var(--dsw-alias-state-error-primary)' } },
|
|
]])
|
|
})
|
|
})
|
|
|
|
describe('parseAnsiLines: SGR across lines', () => {
|
|
it('carries active state past a newline, as a terminal does', () => {
|
|
// Verified in a real terminal: `\x1b[31mabc\rX\nnext` paints BOTH lines red.
|
|
// A newline does not reset the graphic state, so a replayed line must hand
|
|
// its state to the next one instead of closing it off.
|
|
expect(parseAnsiLines(`${ESC}[31mabc\rX\nnext`)).toEqual([
|
|
[{ text: 'Xbc', style: { color: 'var(--dsw-alias-state-error-primary)' } }],
|
|
[{ text: 'next', style: { color: 'var(--dsw-alias-state-error-primary)' } }],
|
|
])
|
|
})
|
|
|
|
it('tracks state through a line that needs no replay', () => {
|
|
// The middle line has no movement, so it is not replayed — but its own SGR
|
|
// still has to reach the line after it.
|
|
expect(parseAnsiLines(`a\r${ESC}[32mb\nplain\nc`)).toEqual([
|
|
[{ text: 'b', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
|
|
[{ text: 'plain', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
|
|
[{ text: 'c', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('parseAnsiLines: runs spanning lines', () => {
|
|
it('carries one run\'s style onto every line it covers', () => {
|
|
expect(parseAnsiLines(sgr('32', 'first\nsecond'))).toEqual([
|
|
[{ text: 'first', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
|
|
[{ text: 'second', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
|
|
])
|
|
})
|
|
|
|
it('keeps several runs of one line in order', () => {
|
|
expect(parseAnsiLines(`plain${sgr('31', 'red')}tail`)).toEqual([[
|
|
{ text: 'plain', style: undefined },
|
|
{ text: 'red', style: { color: 'var(--dsw-alias-state-error-primary)' } },
|
|
{ text: 'tail', style: undefined },
|
|
]])
|
|
})
|
|
})
|