Files
NetMesh/components/terminal/runtime/cursorLineHighlight.test.ts

499 lines
16 KiB
TypeScript
Raw Permalink Normal View History

import test from 'node:test';
import assert from 'node:assert/strict';
import { CursorLineHighlighter } from './cursorLineHighlight.ts';
type Handler = () => void;
type FakeElement = {
style: Record<string, string>;
attributes: Record<string, string>;
setAttribute: (name: string, value: string) => void;
};
const createFakeTerm = (cols = 80) => {
let cursorY = 0;
let cursorX = 0;
let hasSelection = false;
let baseY = 0;
let bufferType: 'normal' | 'alternate' = 'normal';
let lineLength = cols;
const coloredBackgrounds = new Set<number>();
const coloredForegrounds = new Set<number>();
const inverseCells = new Set<number>();
const cursorMoveHandlers: Handler[] = [];
const writeParsedHandlers: Handler[] = [];
const resizeHandlers: Handler[] = [];
const bufferChangeHandlers: Handler[] = [];
const renderHandlers: Handler[] = [];
const selectionChangeHandlers: Handler[] = [];
const decorations: Array<{
options: Record<string, unknown>;
disposed: boolean;
element: FakeElement;
dispose: () => void;
onRender: (handler: (element: FakeElement) => void) => { dispose: () => void };
onDispose: (handler: Handler) => { dispose: () => void };
}> = [];
const markers: Array<{ line: number; disposed: boolean }> = [];
const term = {
cols,
buffer: {
active: {
get type() {
return bufferType;
},
get baseY() {
return baseY;
},
get cursorY() {
return cursorY;
},
get cursorX() {
return cursorX;
},
getLine() {
return {
length: cols,
getCell(x: number) {
return {
getChars: () => (x < lineLength ? 'x' : ''),
getWidth: () => 1,
isAttributeDefault: () => x >= lineLength,
isBgDefault: () => !coloredBackgrounds.has(x),
isFgDefault: () => !coloredForegrounds.has(x),
isInverse: () => (inverseCells.has(x) ? 1 : 0),
};
},
};
},
getNullCell() {
return { isBgDefault: () => true };
},
},
onBufferChange(handler: Handler) {
bufferChangeHandlers.push(handler);
return { dispose() {} };
},
},
onCursorMove(handler: Handler) {
cursorMoveHandlers.push(handler);
return { dispose() {} };
},
onResize(handler: Handler) {
resizeHandlers.push(handler);
return { dispose() {} };
},
onWriteParsed(handler: Handler) {
writeParsedHandlers.push(handler);
return { dispose() {} };
},
onRender(handler: Handler) {
renderHandlers.push(handler);
return { dispose() {} };
},
onSelectionChange(handler: Handler) {
selectionChangeHandlers.push(handler);
return { dispose() {} };
},
hasSelection() {
return hasSelection;
},
registerMarker(offset: number) {
const marker = {
line: baseY + cursorY + offset,
disposed: false,
get isDisposed() {
return this.disposed;
},
dispose() {
this.disposed = true;
},
};
markers.push(marker);
return marker;
},
registerDecoration(options: Record<string, unknown>) {
const disposeHandlers = new Set<Handler>();
const element: FakeElement = {
style: {},
attributes: {},
setAttribute(name, value) {
this.attributes[name] = value;
},
};
const decoration = {
options,
disposed: false,
element,
dispose() {
if (this.disposed) return;
this.disposed = true;
for (const handler of disposeHandlers) handler();
disposeHandlers.clear();
},
onDispose(handler: Handler) {
disposeHandlers.add(handler);
return { dispose: () => disposeHandlers.delete(handler) };
},
onRender(handler: (element: FakeElement) => void) {
handler(element);
return { dispose() {} };
},
};
decorations.push(decoration);
return decoration;
},
moveCursor(nextY: number) {
cursorY = nextY;
for (const handler of cursorMoveHandlers) handler();
for (const handler of renderHandlers) handler();
},
scrollOutput(lines: number) {
baseY += lines;
for (const handler of writeParsedHandlers) handler();
for (const handler of renderHandlers) handler();
},
writeHiddenOutput(lines: number) {
baseY += lines;
for (const handler of writeParsedHandlers) handler();
},
trimScrollback(lines: number) {
for (const marker of markers) {
if (!marker.disposed) marker.line -= lines;
}
for (const handler of writeParsedHandlers) handler();
for (const handler of renderHandlers) handler();
},
setBufferType(nextType: 'normal' | 'alternate') {
bufferType = nextType;
for (const handler of bufferChangeHandlers) handler();
},
setColoredBackgrounds(columns: number[]) {
coloredBackgrounds.clear();
for (const column of columns) coloredBackgrounds.add(column);
for (const handler of writeParsedHandlers) handler();
for (const handler of renderHandlers) handler();
},
setLineLength(nextLength: number) {
lineLength = nextLength;
for (const handler of writeParsedHandlers) handler();
for (const handler of renderHandlers) handler();
},
setCursorX(nextX: number) {
cursorX = nextX;
for (const handler of cursorMoveHandlers) handler();
for (const handler of renderHandlers) handler();
},
setSelection(next: boolean) {
hasSelection = next;
for (const handler of selectionChangeHandlers) handler();
},
render() {
for (const handler of renderHandlers) handler();
},
setColoredForegrounds(columns: number[]) {
coloredForegrounds.clear();
for (const column of columns) coloredForegrounds.add(column);
for (const handler of writeParsedHandlers) handler();
for (const handler of renderHandlers) handler();
},
setInverseCells(columns: number[]) {
inverseCells.clear();
for (const column of columns) inverseCells.add(column);
for (const handler of writeParsedHandlers) handler();
for (const handler of renderHandlers) handler();
},
resetDecorations() {
for (const decoration of decorations) decoration.dispose();
},
setCols(nextCols: number) {
term.cols = nextCols;
for (const handler of resizeHandlers) handler();
},
decorations,
markers,
};
return term;
};
test('CursorLineHighlighter paints an opaque background without changing text', () => {
const term = createFakeTerm(100);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setBackgroundColor('#263449');
highlighter.setEnabled(true);
assert.equal(term.decorations.length, 1);
assert.equal(term.decorations[0]?.options.width, 100);
assert.equal(term.decorations[0]?.options.backgroundColor, '#263449');
assert.equal(term.decorations[0]?.options.layer, 'bottom');
highlighter.dispose();
});
test('CursorLineHighlighter gives selection and search matches priority', () => {
const term = createFakeTerm(10);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.decorations.at(-1)?.disposed, false);
term.setSelection(true);
assert.equal(term.decorations.at(-1)?.disposed, true);
term.setSelection(false);
assert.equal(term.decorations.at(-1)?.disposed, false);
highlighter.dispose();
});
test('CursorLineHighlighter refreshes after hidden writes become visible', () => {
const term = createFakeTerm(10);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.markers.at(-1)?.line, 0);
term.writeHiddenOutput(1);
assert.equal(term.decorations.length, 1);
term.render();
assert.equal(term.decorations.length, 2);
assert.equal(term.decorations[0]?.disposed, true);
assert.equal(term.markers.at(-1)?.line, 1);
highlighter.dispose();
});
test('CursorLineHighlighter keeps a continuous background under colored keyword text', () => {
const term = createFakeTerm(10);
const keywordMarker = term.registerMarker(0);
term.registerDecoration({
marker: keywordMarker,
x: 2,
width: 2,
foregroundColor: '#F87171',
});
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setBackgroundColor('#263449');
highlighter.setEnabled(true);
assert.equal(term.decorations[0]?.options.foregroundColor, '#F87171');
assert.equal(term.decorations[0]?.options.backgroundColor, undefined);
assert.equal(term.decorations[1]?.options.x, 0);
assert.equal(term.decorations[1]?.options.width, 10);
assert.equal(term.decorations[1]?.options.backgroundColor, '#263449');
assert.equal(term.decorations[1]?.options.layer, 'bottom');
highlighter.dispose();
});
test('CursorLineHighlighter leaves ANSI background cells untouched', () => {
const term = createFakeTerm(10);
term.setColoredBackgrounds([2, 3, 7]);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setBackgroundColor('#263449');
highlighter.setEnabled(true);
assert.deepEqual(
term.decorations.map(({ options }) => ({ x: options.x, width: options.width })),
[
{ x: 0, width: 2 },
{ x: 4, width: 3 },
{ x: 8, width: 2 },
],
);
highlighter.dispose();
});
test('CursorLineHighlighter fills the blank tail after short output', () => {
const term = createFakeTerm(10);
term.setLineLength(4);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setBackgroundColor('#263449');
highlighter.setEnabled(true);
assert.equal(term.decorations.length, 2);
assert.equal(term.decorations[0]?.options.width, 4);
assert.equal(term.decorations[1]?.options.x, 4);
assert.equal(term.decorations[1]?.options.width, 6);
assert.equal(term.decorations[1]?.options.backgroundColor, '#263449');
assert.equal(term.decorations[1]?.options.layer, 'bottom');
highlighter.dispose();
});
test('CursorLineHighlighter fills the blank tail through the cursor cell', () => {
const term = createFakeTerm(10);
term.setLineLength(4);
term.setCursorX(5);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setBackgroundColor('#263449');
highlighter.setEnabled(true);
assert.deepEqual(
term.decorations.map(({ options }) => ({ x: options.x, width: options.width })),
[
{ x: 0, width: 4 },
{ x: 4, width: 6 },
],
);
highlighter.dispose();
});
test('CursorLineHighlighter fills a wrap-pending final cell', () => {
const term = createFakeTerm(10);
term.setLineLength(4);
term.setCursorX(10);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setBackgroundColor('#263449');
highlighter.setEnabled(true);
assert.deepEqual(
term.decorations.map(({ options }) => ({ x: options.x, width: options.width })),
[
{ x: 0, width: 4 },
{ x: 4, width: 6 },
],
);
highlighter.dispose();
});
test('CursorLineHighlighter keeps foreground colors and leaves inverse cells untouched', () => {
const term = createFakeTerm(10);
term.setColoredForegrounds([2, 3]);
term.setInverseCells([7]);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setBackgroundColor('#263449');
highlighter.setEnabled(true);
assert.deepEqual(
term.decorations.map(({ options }) => ({ x: options.x, width: options.width })),
[
{ x: 0, width: 7 },
{ x: 8, width: 2 },
],
);
highlighter.dispose();
});
test('CursorLineHighlighter follows cursor moves and clears when disabled', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.decorations.length, 1);
term.moveCursor(3);
assert.equal(term.decorations.length, 2);
assert.equal(term.decorations[0]?.disposed, true);
assert.equal(term.decorations[1]?.disposed, false);
highlighter.setEnabled(false);
assert.equal(term.decorations[1]?.disposed, true);
highlighter.dispose();
});
test('CursorLineHighlighter swaps decorations atomically on refresh', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
const firstDecoration = term.decorations[0];
assert.ok(firstDecoration);
assert.equal(firstDecoration.disposed, false);
let sawOverlap = false;
const originalRegisterMarker = term.registerMarker.bind(term);
term.registerMarker = (offset: number) => {
// New marker must be created while the previous decoration is still live.
if (!firstDecoration.disposed) sawOverlap = true;
return originalRegisterMarker(offset);
};
term.moveCursor(2);
assert.equal(sawOverlap, true, 'new marker should register before old decoration disposal');
assert.equal(firstDecoration.disposed, true);
assert.equal(term.decorations.at(-1)?.disposed, false);
highlighter.dispose();
});
test('CursorLineHighlighter recreates on resize and overlay color changes', () => {
const term = createFakeTerm(40);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
highlighter.setBackgroundColor('#112233');
assert.equal(
term.decorations.at(-1)?.options.backgroundColor,
'#112233',
);
assert.equal(term.decorations.at(-1)?.options.width, 40);
term.setCols(120);
assert.equal(term.decorations.at(-2)?.options.width, 40);
assert.equal(term.decorations.at(-1)?.options.width, 80);
highlighter.setBackgroundColor('#445566');
assert.equal(
term.decorations.at(-2)?.options.backgroundColor,
'#445566',
);
highlighter.dispose();
});
test('CursorLineHighlighter follows bottom-row output when only baseY changes', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.markers.at(-1)?.line, 0);
term.scrollOutput(3);
assert.equal(term.decorations.length, 2);
assert.equal(term.decorations[0]?.disposed, true);
assert.equal(term.markers.at(-1)?.line, 3);
assert.equal(term.decorations.at(-1)?.disposed, false);
highlighter.dispose();
});
test('CursorLineHighlighter refreshes when saturated scrollback moves its marker', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
const originalMarker = term.markers.at(-1);
assert.equal(originalMarker?.line, 0);
term.trimScrollback(1);
assert.equal(originalMarker?.disposed, true);
assert.equal(term.decorations.length, 2);
assert.equal(term.markers.at(-1)?.line, 0);
assert.equal(term.decorations.at(-1)?.disposed, false);
highlighter.dispose();
});
test('CursorLineHighlighter restores after the terminal resets its decorations', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.decorations.length, 1);
term.resetDecorations();
assert.equal(term.decorations[0]?.disposed, true);
term.scrollOutput(0);
assert.equal(term.decorations.length, 2);
assert.equal(term.decorations.at(-1)?.disposed, false);
highlighter.dispose();
});
test('CursorLineHighlighter clears in the alternate buffer and restores in normal buffer', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.decorations.at(-1)?.disposed, false);
term.setBufferType('alternate');
assert.equal(term.decorations.at(-1)?.disposed, true);
const decorationCount = term.decorations.length;
term.moveCursor(4);
term.scrollOutput(1);
assert.equal(term.decorations.length, decorationCount);
term.setBufferType('normal');
assert.equal(term.decorations.length, decorationCount + 1);
assert.equal(term.decorations.at(-1)?.disposed, false);
highlighter.dispose();
});