import test from 'node:test'; import assert from 'node:assert/strict'; import { CursorLineHighlighter } from './cursorLineHighlight.ts'; type Handler = () => void; type FakeElement = { style: Record; attributes: Record; 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(); const coloredForegrounds = new Set(); const inverseCells = new Set(); const cursorMoveHandlers: Handler[] = []; const writeParsedHandlers: Handler[] = []; const resizeHandlers: Handler[] = []; const bufferChangeHandlers: Handler[] = []; const renderHandlers: Handler[] = []; const selectionChangeHandlers: Handler[] = []; const decorations: Array<{ options: Record; 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) { const disposeHandlers = new Set(); 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(); });