[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
273
components/terminal/runtime/cursorLineHighlight.ts
Normal file
273
components/terminal/runtime/cursorLineHighlight.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
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;
|
||||
});
|
||||
Reference in New Issue
Block a user