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

274 lines
7.9 KiB
TypeScript
Raw Normal View History

import type {
IDecoration,
IDisposable,
IMarker,
Terminal as XTerm,
} from '@xterm/xterm';
type CursorLineTerminal = Pick<
XTerm,
| 'cols'
| 'buffer'
| 'registerMarker'
| 'registerDecoration'
| 'onCursorMove'
| 'onResize'
| 'onWriteParsed'
| 'onRender'
| 'onSelectionChange'
| 'hasSelection'
>;
type HighlightRange = { x: number; width: number };
/**
* Highlights the buffer row under the cursor without tinting its glyphs.
* Explicit ANSI backgrounds remain untouched. Foreground-only colors still
* receive the opaque theme background so colored text does not leave holes.
*/
export class CursorLineHighlighter implements IDisposable {
private enabled = false;
private backgroundColor = '#263449';
private marker: IMarker | null = null;
private decorations: IDecoration[] = [];
private decorationDisposeListeners: IDisposable[] = [];
private activeLine: number | null = null;
private activeCols: number | null = null;
private activeColor: string | null = null;
private activeRanges: HighlightRange[] = [];
private activeTailRanges: HighlightRange[] = [];
private readonly disposables: IDisposable[] = [];
private pendingRefresh = false;
private disposed = false;
constructor(private readonly term: CursorLineTerminal) {
this.disposables.push(
this.term.onCursorMove(() => this.markPendingRefresh()),
this.term.onWriteParsed(() => this.markPendingRefresh()),
this.term.onRender(() => {
if (this.pendingRefresh) {
this.pendingRefresh = false;
this.refresh();
}
}),
this.term.onSelectionChange(() => this.refresh({ force: true })),
this.term.onResize(() => this.refresh({ force: true })),
this.term.buffer.onBufferChange(() => this.refresh({ force: true })),
);
}
setEnabled(enabled: boolean): void {
if (this.disposed) return;
if (this.enabled === enabled) {
if (enabled) this.refresh();
return;
}
this.enabled = enabled;
if (!enabled) {
this.clear();
return;
}
this.refresh({ force: true });
}
setBackgroundColor(color: string): void {
if (this.disposed) return;
const next = color.trim();
if (!next || next === this.backgroundColor) return;
this.backgroundColor = next;
if (this.enabled) this.refresh({ force: true });
}
refresh(options: { force?: boolean } = {}): void {
if (this.disposed || !this.enabled) return;
this.pendingRefresh = false;
if (this.term.hasSelection()) {
this.clear();
return;
}
const buffer = this.term.buffer.active;
if (buffer.type === 'alternate') {
this.clear();
return;
}
const absoluteLine = buffer.baseY + buffer.cursorY;
const cols = Math.max(1, this.term.cols || 1);
const color = this.backgroundColor;
const line = buffer.getLine(absoluteLine);
const { ranges, contentEnd } = this.getDefaultBackgroundRanges(
line,
cols,
buffer.getNullCell(),
);
const tailRanges = this.getTailRanges(contentEnd, cols);
if (
!options.force &&
absoluteLine === this.activeLine &&
cols === this.activeCols &&
color === this.activeColor &&
rangesEqual(ranges, this.activeRanges) &&
rangesEqual(tailRanges, this.activeTailRanges) &&
this.marker &&
!this.marker.isDisposed &&
this.marker.line === absoluteLine
) {
return;
}
// Register the next marker/decorations before disposing the previous set so
// Enter / cursor moves never leave an empty frame (clear-then-create flash).
const previousMarker = this.marker;
const previousDecorations = this.decorations;
const previousDisposeListeners = this.decorationDisposeListeners;
const marker = this.term.registerMarker(0);
if (!marker) {
this.clearOwned(
previousMarker,
previousDecorations,
previousDisposeListeners,
);
return;
}
const decorations: IDecoration[] = [];
for (const range of ranges) {
const decoration = this.term.registerDecoration({
marker,
x: range.x,
width: range.width,
backgroundColor: color,
layer: 'bottom',
});
if (decoration) decorations.push(decoration);
}
for (const tailRange of tailRanges) {
const tailDecoration = this.term.registerDecoration({
marker,
x: tailRange.x,
width: tailRange.width,
backgroundColor: color,
layer: 'bottom',
});
if (tailDecoration) {
decorations.push(tailDecoration);
}
}
this.marker = marker;
this.decorations = decorations;
this.decorationDisposeListeners = decorations.map((decoration) =>
decoration.onDispose(() => {
if (this.decorations.includes(decoration)) {
this.activeLine = null;
this.activeCols = null;
this.activeColor = null;
this.activeRanges = [];
}
}),
);
this.activeLine = absoluteLine;
this.activeCols = cols;
this.activeColor = color;
this.activeRanges = ranges;
this.activeTailRanges = tailRanges;
this.clearOwned(
previousMarker,
previousDecorations,
previousDisposeListeners,
);
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.clear();
for (const disposable of this.disposables) {
disposable.dispose();
}
this.disposables.length = 0;
}
private getDefaultBackgroundRanges(
line: ReturnType<CursorLineTerminal['buffer']['active']['getLine']>,
cols: number,
cell: ReturnType<CursorLineTerminal['buffer']['active']['getNullCell']>,
): { ranges: HighlightRange[]; contentEnd: number } {
const ranges: HighlightRange[] = [];
let rangeStart: number | null = null;
let contentEnd = 0;
for (let x = 0; x < cols; x += 1) {
const currentCell = line?.getCell(x, cell);
if (
currentCell &&
(currentCell.getChars() !== '' || !currentCell.isAttributeDefault())
) {
contentEnd = Math.min(cols, x + Math.max(1, currentCell.getWidth()));
}
const isHighlightable =
currentCell === undefined ||
(currentCell.isBgDefault() && !currentCell.isInverse());
if (isHighlightable && rangeStart === null) rangeStart = x;
if ((!isHighlightable || x === cols - 1) && rangeStart !== null) {
const end = isHighlightable && x === cols - 1 ? x + 1 : x;
ranges.push({ x: rangeStart, width: end - rangeStart });
rangeStart = null;
}
}
return {
ranges: ranges
.map((range) => {
const end = Math.min(range.x + range.width, contentEnd);
return { x: range.x, width: end - range.x };
})
.filter((range) => range.width > 0),
contentEnd,
};
}
private getTailRanges(contentEnd: number, cols: number): HighlightRange[] {
if (contentEnd >= cols) return [];
return [{ x: contentEnd, width: cols - contentEnd }];
}
private clear(): void {
this.clearOwned(
this.marker,
this.decorations,
this.decorationDisposeListeners,
);
this.marker = null;
this.decorations = [];
this.decorationDisposeListeners = [];
this.activeLine = null;
this.activeCols = null;
this.activeColor = null;
this.activeRanges = [];
this.activeTailRanges = [];
}
private clearOwned(
marker: IMarker | null,
decorations: IDecoration[],
disposeListeners: IDisposable[],
): void {
for (const disposable of disposeListeners) disposable.dispose();
for (const decoration of decorations) decoration.dispose();
marker?.dispose();
}
private markPendingRefresh(): void {
if (!this.disposed && this.enabled) this.pendingRefresh = true;
}
}
const rangesEqual = (left: HighlightRange[], right: HighlightRange[]): boolean =>
left.length === right.length && left.every((range, index) => {
const other = right[index];
return other?.x === range.x && other.width === range.width;
});