Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
274 lines
7.9 KiB
TypeScript
274 lines
7.9 KiB
TypeScript
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;
|
|
});
|