import * as vscode from 'vscode'; import { normalizeEol, offsetOf, positionOf } from '../util/textDiff'; import type { LiveCursor, LiveSelection } from './liveBridge'; /** One colour per person, the same in every window. */ const COLORS = ['#e5534b', '#57ab5a', '#539bf5', '#c69026', '#b083f0', '#39c5cf', '#e0823d', '#d16d9e']; export function colorFor(id: string): string { let hash = 0; for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0; return COLORS[hash % COLORS.length]; } /** What the text-editor side needs from the shared models. */ export interface CursorSource { cursors(file: vscode.Uri): LiveCursor[]; setCursor(file: vscode.Uri, selection: LiveSelection | undefined): boolean; } interface Styles { caret: vscode.TextEditorDecorationType; selection: vscode.TextEditorDecorationType; } /** * Cursors in VS Code's text editors: yours go to the others as you move, and * theirs are drawn as a coloured caret with their name and a tinted selection. */ export class EditorCursors implements vscode.Disposable { private readonly styles = new Map(); private readonly disposables: vscode.Disposable[] = []; private timer: ReturnType | undefined; private pending: vscode.TextEditor | undefined; constructor(private readonly source: CursorSource) { this.disposables.push( vscode.window.onDidChangeTextEditorSelection((event) => this.moved(event.textEditor)), vscode.window.onDidChangeActiveTextEditor((editor) => editor && this.moved(editor)), vscode.window.onDidChangeVisibleTextEditors(() => this.render()), vscode.workspace.onDidChangeTextDocument((event) => { if (vscode.window.visibleTextEditors.some((e) => e.document === event.document)) this.render(); }), ); } /** Sent at most every 80 ms; the last position wins. */ private moved(editor: vscode.TextEditor): void { if (editor.document.uri.scheme !== 'file') return; this.pending = editor; if (this.timer) return; this.timer = setTimeout(() => { this.timer = undefined; const e = this.pending; this.pending = undefined; if (!e || e.document.isClosed) return; const text = normalizeEol(e.document.getText()); const { anchor, active } = e.selection; this.source.setCursor(e.document.uri, { anchor: offsetOf(text, anchor.line, anchor.character), head: offsetOf(text, active.line, active.character), }); }, 80); } private stylesFor(color: string): Styles { let found = this.styles.get(color); if (!found) { found = { caret: vscode.window.createTextEditorDecorationType({ rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed, // A zero-width caret and the name above it; the CSS rides in textDecoration, // the usual way to place a decoration without moving the text. before: { contentText: '', textDecoration: `none; position: absolute; display: inline-block; height: 1.25em; border-left: 2px solid ${color}; margin-left: -1px; z-index: 1;`, }, }), selection: vscode.window.createTextEditorDecorationType({ backgroundColor: `${color}38`, rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed, }), }; this.styles.set(color, found); } return found; } /** Draws everyone's cursors in the visible editors of shared files. */ render(): void { for (const editor of vscode.window.visibleTextEditors) { if (editor.document.uri.scheme !== 'file') continue; const cursors = this.source.cursors(editor.document.uri); const text = normalizeEol(editor.document.getText()); const at = (offset: number) => { const { line, character } = positionOf(text, Math.max(0, Math.min(offset, text.length))); return new vscode.Position(line, character); }; const byColor = new Map(); for (const c of cursors) { let group = byColor.get(c.color); if (!group) byColor.set(c.color, (group = { carets: [], selections: [] })); const head = at(c.head); group.carets.push({ range: new vscode.Range(head, head), hoverMessage: new vscode.MarkdownString(`**${c.name.replace(/[\\`*_[\]<>]/g, '')}** is here`), renderOptions: { after: { contentText: c.name, color: '#fff', backgroundColor: c.color, fontWeight: '500', textDecoration: 'none; position: absolute; transform: translateY(-1.15em); font-size: 0.75em; padding: 0 4px; border-radius: 3px; white-space: nowrap; pointer-events: none; z-index: 2; opacity: 0.9;', }, }, }); if (c.anchor !== c.head) { const [from, to] = c.anchor < c.head ? [c.anchor, c.head] : [c.head, c.anchor]; group.selections.push(new vscode.Range(at(from), at(to))); } } for (const color of byColor.keys()) this.stylesFor(color); // Every colour is set, so a person who left or moved away disappears. for (const [color, styles] of this.styles) { const group = byColor.get(color); editor.setDecorations(styles.caret, group?.carets ?? []); editor.setDecorations(styles.selection, group?.selections ?? []); } } } dispose(): void { clearTimeout(this.timer); for (const styles of this.styles.values()) { styles.caret.dispose(); styles.selection.dispose(); } this.styles.clear(); for (const d of this.disposables.splice(0)) d.dispose(); } }