feat(editor): Rainbow brackets and semantic highlighting (rainbow variables) (#59)

Two editor toggles under Settings › Editor › Color Scheme › Language Defaults (off by default, also actions `editor.toggleRainbowBrackets` / `editor.toggleSemanticHighlighting`):
- Semantic highlighting (IDEA's rainbow variables): locals and parameters coloured by a name hash from LSP semantic tokens; fields, statics, constants, library symbols untouched.
- Rainbow brackets (VS Code's bracket pair colorization): (), [], {} by depth, unmatched in the error colour, strings/comments skipped, visible ranges only with 4 KB checkpoints.
Palettes are theme tokens (`--syn-rainbow-1..5`, `--syn-bracket-1..3`, light/dark/high-contrast), editable in the Color Scheme tree, and mapped from IntelliJ `.icls` RAINBOW_COLOR1..5.

App verification on macOS found the colours were overridden by the syntax highlighter's span nested inside the marks; the rules now cover descendants. Verified both toggles on/off live in a TS file.

Fixes #56

https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
This commit is contained in:
l0ng-ai authored and GitHub committed 2026-10-27 11:52:07 +08:00
1 parent 2e0c74e31d
commit beab7992f9
17 files changed
+679 -9

No files matched your search

+7
View File
@@ -289,6 +289,11 @@ pub struct EditorSettings {
pub color_scheme: String,
/// Color Scheme edits: scheme id → attribute id → override.
pub color_overrides: BTreeMap<String, BTreeMap<String, TextAttributesOverride>>,
/// Color Scheme › Language Defaults › Semantic highlighting: local variables and
/// parameters get a colour derived from their name (IDEA's rainbow identifiers).
pub semantic_highlighting: bool,
/// Color Scheme › Language Defaults › Rainbow brackets: `()`, `[]`, `{}` coloured by depth.
pub rainbow_brackets: bool,
pub ligatures: bool,
/// Fallback font for glyphs the main font lacks; empty = none.
pub fallback_font: String,
@@ -329,6 +334,8 @@ impl Default for EditorSettings {
format_on_save: false,
color_scheme: "theme-default".into(),
color_overrides: BTreeMap::new(),
semantic_highlighting: false,
rainbow_brackets: false,
ligatures: false,
fallback_font: String::new(),
font_weight: 400,
+11 -1
View File
@@ -195,6 +195,7 @@ The full table is `UI_KEYS` in [`scripts/theme/intellij.ts`](../../scripts/theme
| `--syn-annotation`, `--syn-decorator` | `DEFAULT_METADATA` |
| `--syn-punct`, `--syn-escape` | `DEFAULT_OPERATION_SIGN`, `DEFAULT_VALID_STRING_ESCAPE` |
| `--syn-tag`, `--syn-attribute`, `--syn-todo`, `--syn-invalid` | `DEFAULT_TAG`, `DEFAULT_ATTRIBUTE`, `TODO_DEFAULT_ATTRIBUTES`, `BAD_CHARACTER` |
| `--syn-rainbow-1` … `--syn-rainbow-5` (Semantic highlighting) | `RAINBOW_COLOR1` … `RAINBOW_COLOR5` |
| `--wave-error`, `--wave-warning` | `ERRORS_ATTRIBUTES`, `WARNING_ATTRIBUTES` (effect colour) |
| `--bg-matched-brace`, `--bg-identifier-under-caret` | `MATCHED_BRACE_ATTRIBUTES`, `IDENTIFIER_UNDER_CARET_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-match` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
@@ -238,6 +239,7 @@ from `type`, then map:
| `--fg-whitespace` | `editorWhitespace.foreground` |
| `--bg-text-search-result`, `--bg-text-search-result-current` | `editor.findMatchHighlightBackground`, `editor.findMatchBackground` |
| `--bg-matched-brace` | `editorBracketMatch.background` |
| `--syn-bracket-1` … `--syn-bracket-3` (Rainbow brackets) | `editorBracketHighlight.foreground1` … `foreground3` |
| `--bg-identifier-under-caret`, `-write` | `editor.wordHighlightBackground`, `editor.wordHighlightStrongBackground` |
| `--wave-error`, `--wave-warning` | `editorError.foreground`, `editorWarning.foreground` |
| `--gutter-modified`, `--gutter-added`, `--gutter-deleted` | `editorGutter.modifiedBackground`, `.addedBackground`, `.deletedBackground` |
@@ -292,7 +294,7 @@ a token.
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
194 themeable tokens: 112 in the UI theme file, 82 in the editor colour scheme file.
202 themeable tokens: 112 in the UI theme file, 90 in the editor colour scheme file.
### Surfaces (UI theme)
@@ -401,6 +403,14 @@ a token.
| `--syn-markup-code` | `#6aab73` | `#067d17` |
| `--syn-invalid` | `#f75464` | `#f50000` |
| `--syn-inline-value` | `#868a91` | `#8c8c8c` |
| `--syn-rainbow-1` | `#6fafbd` | `#9b3b6a` |
| `--syn-rainbow-2` | `#d79b6e` | `#114d77` |
| `--syn-rainbow-3` | `#9fbf6b` | `#9c6a2c` |
| `--syn-rainbow-4` | `#c98bc6` | `#005910` |
| `--syn-rainbow-5` | `#8e9cf0` | `#bc5150` |
| `--syn-bracket-1` | `#e8c25b` | `#0431fa` |
| `--syn-bracket-2` | `#d78bd6` | `#319331` |
| `--syn-bracket-3` | `#4ea7f0` | `#7b3814` |
### Editor (editor scheme)
+5
View File
@@ -135,6 +135,11 @@ export const ICLS_ATTRIBUTES: Record<string, Array<[Part, string]>> = {
DEFAULT_ATTRIBUTE: [["FOREGROUND", "--syn-attribute"]],
TODO_DEFAULT_ATTRIBUTES: [["FOREGROUND", "--syn-todo"]],
BAD_CHARACTER: [["FOREGROUND", "--syn-invalid"]],
RAINBOW_COLOR1: [["FOREGROUND", "--syn-rainbow-1"]],
RAINBOW_COLOR2: [["FOREGROUND", "--syn-rainbow-2"]],
RAINBOW_COLOR3: [["FOREGROUND", "--syn-rainbow-3"]],
RAINBOW_COLOR4: [["FOREGROUND", "--syn-rainbow-4"]],
RAINBOW_COLOR5: [["FOREGROUND", "--syn-rainbow-5"]],
ERRORS_ATTRIBUTES: [["EFFECT_COLOR", "--wave-error"]],
WARNING_ATTRIBUTES: [["EFFECT_COLOR", "--wave-warning"]],
NOT_USED_ELEMENT_ATTRIBUTES: [["FOREGROUND", "--fg-unused"]],
+14
View File
@@ -2016,6 +2016,13 @@ export type EditorSettings_Deserialize = {
colorScheme?: string,
/** Color Scheme edits: scheme id → attribute id → override. */
colorOverrides?: { [key in string]: { [key in string]: TextAttributesOverride_Deserialize } },
/**
* Color Scheme › Language Defaults › Semantic highlighting: local variables and
* parameters get a colour derived from their name (IDEA's rainbow identifiers).
*/
semanticHighlighting?: boolean,
/** Color Scheme › Language Defaults › Rainbow brackets: `()`, `[]`, `{}` coloured by depth. */
rainbowBrackets?: boolean,
ligatures?: boolean,
/** Fallback font for glyphs the main font lacks; empty = none. */
fallbackFont?: string,
@@ -2056,6 +2063,13 @@ export type EditorSettings_Serialize = {
colorScheme: string,
/** Color Scheme edits: scheme id → attribute id → override. */
colorOverrides: { [key in string]: { [key in string]: TextAttributesOverride_Serialize } },
/**
* Color Scheme › Language Defaults › Semantic highlighting: local variables and
* parameters get a colour derived from their name (IDEA's rainbow identifiers).
*/
semanticHighlighting: boolean,
/** Color Scheme › Language Defaults › Rainbow brackets: `()`, `[]`, `{}` coloured by depth. */
rainbowBrackets: boolean,
ligatures: boolean,
/** Fallback font for glyphs the main font lacks; empty = none. */
fallbackFont: string,
+33
View File
@@ -40,6 +40,9 @@ import { useDocMeta, useOutline } from "./model/document";
import { fontZoom, setFontZoom } from "./cm/prefs";
import { useEditorRegistry } from "@/editor/api";
import { useProject } from "@/platform/project";
import { call, commands } from "@/platform/ipc";
import { editorPrefs } from "@/platform/editorSettings";
import { notifications } from "@/workbench/contributions";
import { toggleReadOnly } from "./fileAttributes";
import { openNavBarPopup } from "./view/NavBarPopup";
import {
@@ -298,6 +301,26 @@ function addNextOccurrence(view: EditorView) {
if (nextOccurrence(view) === "notFound") void executeAction("editor.showHint", { text: "Not found, press again to search from the top" });
}
/** Settings › Editor › Color Scheme switches that are also toggle actions. */
type SchemeSwitch = "semanticHighlighting" | "rainbowBrackets";
export async function toggleSchemeSwitch(key: SchemeSwitch, on = !editorPrefs()[key]): Promise<void> {
try {
useProject.setState({ settings: await call(commands.settingsSet("global", `editor.${key}`, JSON.stringify(on))) });
} catch (e) {
notifications().notify({ level: "error", title: "Cannot change the setting", body: e instanceof Error ? e.message : String(e) });
}
}
const schemeSwitch = (id: string, title: string, key: SchemeSwitch, keywords: string[]): Action => ({
id,
title,
category: "Editor",
keywords,
toggled: () => editorPrefs()[key],
run: () => toggleSchemeSwitch(key),
});
// ---------------------------------------------------------------------------------
export function registerEditorActions(): () => void {
@@ -316,6 +339,16 @@ export function registerEditorActions(): () => void {
return pane?.active ? { paneId: pane.id, tabId: pane.active } : null;
};
return registerActions([
schemeSwitch("editor.toggleSemanticHighlighting", "Semantic Highlighting", "semanticHighlighting", [
"rainbow variables",
"rainbow identifiers",
"color scheme",
]),
schemeSwitch("editor.toggleRainbowBrackets", "Rainbow Brackets", "rainbowBrackets", [
"bracket pair colorization",
"rainbow parentheses",
"color scheme",
]),
text("code.commentLine", "Comment with Line Comment", (v) => commentLine(v)),
text("code.commentBlock", "Comment with Block Comment", (v) => toggleBlockComment(v)),
text("code.duplicateLine", "Duplicate Line or Selection", (v) => duplicateLineOrSelection(v)),
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { EditorState, type Extension } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { ensureSyntaxTree } from "@codemirror/language";
import { languages } from "@codemirror/language-data";
import { BracketScanner, bracketClass } from "./rainbowBrackets";
import { heavyExtension } from "./setup";
import type { EditorSettings } from "@/platform/ipc";
const cStyle = EditorState.languageData.of(() => [
{ commentTokens: { line: "//", block: { open: "/*", close: "*/" } } },
]);
/** `char@depth` of every coloured bracket (`!` for unmatched). */
function marks(doc: string, ext: Extension = [], from = 0, to = doc.length, scanner = new BracketScanner()) {
const state = EditorState.create({ doc, extensions: ext });
ensureSyntaxTree(state, doc.length, 5000);
return scanner.marks(state, from, to).map((m) => `${doc[m.from]}${m.depth < 0 ? "!" : m.depth}`);
}
const javascript = () => languages.find((l) => l.name === "JavaScript")!.load();
describe("rainbow brackets", () => {
it("colours brackets by nesting depth, cycling through the palette", () => {
expect(marks("f(a[b{c}d]e)")).toEqual(["(0", "[1", "{2", "}2", "]1", ")0"]);
expect([0, 1, 2, 3, 4].map(bracketClass)).toEqual([
"cm-rainbow-bracket-1",
"cm-rainbow-bracket-2",
"cm-rainbow-bracket-3",
"cm-rainbow-bracket-1",
"cm-rainbow-bracket-2",
]);
});
it("marks unmatched and mismatched brackets", () => {
expect(marks("a)b")).toEqual([")!"]);
expect(marks("(]")).toEqual(["(!", "]!"]);
expect(marks("x(y")).toEqual(["(!"]);
expect(marks("((a)")).toEqual(["(!", "(1", ")1"]);
expect(bracketClass(-1)).toBe("cm-rainbow-bracket-unmatched");
});
it("skips strings and comments lexically when there is no syntax tree", () => {
const doc = "f(\"(\", '[', `{\n)`) // (\n/* ] */ g()";
expect(marks(doc, cStyle)).toEqual(["(0", ")0", "(0", ")0"]);
// Primes and lifetimes are not quotes.
expect(marks("f x' = (x', y)", cStyle)).toEqual(["(0", ")0"]);
});
it("skips strings, comments and regexps with the syntax tree", async () => {
const doc = 'f("(", /[(]/, `{`) // )\n/* ] */ g([1, {a: 2}])';
expect(marks(doc, await javascript())).toEqual(["(0", ")0", "(0", "[1", "{2", "}2", "]1", ")0"]);
});
it("knows the depth at a viewport far into the document and after edits", () => {
const body = "x(y)\n".repeat(5000);
const doc = `{\n${body}[z]\n}`;
const scanner = new BracketScanner();
const at = doc.indexOf("[z]");
expect(marks(doc, cStyle, at, doc.length, scanner)).toEqual(["[1", "]1", "}0"]);
// Checkpoints make the rescan start near the viewport; an edit before it invalidates them.
let state = EditorState.create({ doc, extensions: cStyle });
const tr = state.update({ changes: { from: 100, insert: "(" } });
scanner.invalidate(100);
state = tr.state;
const from = at + 1;
expect(
scanner
.marks(state, from, state.doc.length)
.map((m) => `${state.doc.sliceString(m.from, m.from + 1)}${m.depth < 0 ? "!" : m.depth}`),
).toEqual(["[2", "]2", "}!"]);
});
it("is installed by the Rainbow brackets setting", () => {
const render = (rainbowBrackets: boolean) => {
const view = new EditorView({
state: EditorState.create({
doc: "f(a[b])",
extensions: [cStyle, heavyExtension(7, { rainbowBrackets } as unknown as EditorSettings)],
}),
parent: document.body,
});
const classes = [...view.contentDOM.querySelectorAll("[class*=cm-rainbow-bracket]")].map((e) => e.className);
view.destroy();
return classes;
};
expect(render(false)).toEqual([]);
expect(render(true)).toEqual([
"cm-rainbow-bracket-1",
"cm-rainbow-bracket-2",
"cm-rainbow-bracket-2",
"cm-rainbow-bracket-1",
]);
});
});
+309
View File
@@ -0,0 +1,309 @@
/**
* Rainbow brackets (VS Code's bracket pair colorization): `()`, `[]`, `{}`
* coloured by nesting depth (`--syn-bracket-<n>`, cycling), unmatched ones in
* the error colour. Strings and comments are skipped: with the syntax tree
* when the language has one, else lexically with the language's comment
* tokens and quotes.
*
* Only the viewport is decorated. The nesting state at its start comes from
* checkpoints saved every few KB while scanning, so scrolling and typing
* rescan from the nearest checkpoint before the change, not from the top.
*/
import { Prec, type EditorState, type Extension, type Text } from "@codemirror/state";
import { Decoration, ViewPlugin, type DecorationSet, type EditorView, type ViewUpdate } from "@codemirror/view";
import { syntaxTree } from "@codemirror/language";
type Tree = ReturnType<typeof syntaxTree>;
type NodeType = Tree["type"];
/** Number of `--syn-bracket-<n>` colours the depth cycles through. */
export const BRACKET_COLORS = 3;
const CHUNK = 4096;
/** How far past the viewport to look for the closers of visible openers. */
const LOOKAHEAD = 64_000;
/** Longest comment / quote token the lexical scanner needs to see past a chunk end. */
const TOKEN_SLACK = 8;
/** Closer char code of each opener char code: `(` `[` `{`. */
const CLOSER: Record<number, number> = { 40: 41, 91: 93, 123: 125 };
/** An open bracket; the stack is a persistent list so checkpoints can share it. */
interface Opener {
closer: number;
pos: number;
depth: number;
prev: Opener | null;
}
/** Lexical mode: code, line comment, block comment, string (quote in `quote`). */
type Mode = 0 | 1 | 2 | 3;
interface ScanState {
pos: number;
stack: Opener | null;
mode: Mode;
quote: string;
}
export interface BracketMark {
from: number;
/** 0-based depth, or -1 for an unmatched bracket. */
depth: number;
}
interface CommentTokens {
line?: string;
block?: { open: string; close: string };
}
const SKIP_NODE = /string|comment|char|rune|regexp|textblock|docstring|heredoc/i;
const skipCache = new WeakMap<NodeType, boolean>();
function skipsNode(type: NodeType): boolean {
let v = skipCache.get(type);
if (v === undefined) skipCache.set(type, (v = !type.isTop && SKIP_NODE.test(type.name)));
return v;
}
/**
* Bracket scanner of one editor: keeps the checkpoints between rebuilds and
* drops the ones after an edit (`invalidate`).
*/
export class BracketScanner {
private checkpoints: ScanState[] = [];
private tree: Tree | null = null;
/** Forget checkpoints after `pos` (first changed position of the old document). */
invalidate(pos: number) {
const keep = pos - TOKEN_SLACK;
let n = this.checkpoints.length;
while (n > 0 && this.checkpoints[n - 1]!.pos > keep) n--;
this.checkpoints.length = n;
}
/** Brackets in `[from, to)` with their depth; unmatched ones get depth -1. */
marks(state: EditorState, from: number, to: number): BracketMark[] {
const tree = treeOf(state);
if (tree !== this.tree) {
// A grown tree may reinterpret what was past its old end.
if (this.tree && tree) this.invalidate(Math.min(this.tree.length, tree.length));
else this.checkpoints.length = 0;
this.tree = tree;
}
const doc = state.doc;
to = Math.min(to, doc.length);
const limit = tree ? Math.min(doc.length, tree.length) : doc.length;
const comments = tree ? {} : (state.languageDataAt<CommentTokens>("commentTokens", 0)[0] ?? {});
const out: BracketMark[] = [];
const s = this.start(from);
const visit = (pos: number, depth: number) => {
if (pos >= from && pos < to) out.push({ from: pos, depth });
};
this.scan(doc, tree, comments, s, Math.min(to, limit), visit);
// Visible openers whose closer never comes are unmatched; give up (leave them coloured) past the lookahead.
let open = s.stack;
let shallowest = Infinity;
for (let o = open; o && o.pos >= from; o = o.prev) shallowest = o.depth;
if (shallowest !== Infinity) {
const end = Math.min(limit, to + LOOKAHEAD);
this.scan(doc, tree, comments, s, end, () => {}, shallowest);
open = s.stack;
if (s.pos >= doc.length && (open?.depth ?? -1) >= shallowest) {
const unmatched = new Set<number>();
for (let o = open; o && o.depth >= shallowest; o = o.prev) unmatched.add(o.pos);
for (const m of out) if (unmatched.has(m.from)) m.depth = -1;
}
}
return out;
}
private start(from: number): ScanState {
const cps = this.checkpoints;
let lo = 0;
let hi = cps.length - 1;
let best = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (cps[mid]!.pos <= from) {
best = mid;
lo = mid + 1;
} else hi = mid - 1;
}
return best < 0 ? { pos: 0, stack: null, mode: 0, quote: "" } : { ...cps[best]! };
}
/**
* Advances `s` to `end`, calling `visit` for every bracket. With `untilBelow`,
* stops once the stack is shallower than that depth (lookahead).
*/
private scan(
doc: Text,
tree: Tree | null,
comments: CommentTokens,
s: ScanState,
end: number,
visit: (pos: number, depth: number) => void,
untilBelow = -1,
) {
const cps = this.checkpoints;
const lineFirst = comments.line ? comments.line.charCodeAt(0) : -1;
const blockFirst = comments.block ? comments.block.open.charCodeAt(0) : -1;
while (s.pos < end) {
const chunkEnd = Math.min(end, (Math.floor(s.pos / CHUNK) + 1) * CHUNK);
const base = s.pos;
const text = doc.sliceString(base, Math.min(doc.length, chunkEnd + TOKEN_SLACK));
const stop = chunkEnd - base;
const skips: number[] = [];
if (tree)
tree.iterate({
from: base,
to: chunkEnd,
enter: (n) => {
if (!skipsNode(n.type)) return;
skips.push(n.from - base, n.to - base);
return false;
},
});
let k = 0;
let i = 0;
while (i < stop) {
if (tree) {
while (k < skips.length && skips[k + 1]! <= i) k += 2;
if (k < skips.length && skips[k]! <= i) {
i = skips[k + 1]!;
continue;
}
} else if (s.mode !== 0) {
i = lexSkip(text, i, stop, s, comments);
continue;
}
const ch = text.charCodeAt(i);
if (!tree && (ch === 34 || ch === 39 || ch === 96 || ch === lineFirst || ch === blockFirst)) {
const next = lexEnter(text, i, s, comments);
if (next !== i) {
i = next;
continue;
}
}
if (ch === 40 || ch === 91 || ch === 123) {
const depth = s.stack ? s.stack.depth + 1 : 0;
visit(base + i, depth);
s.stack = { closer: CLOSER[ch]!, pos: base + i, depth, prev: s.stack };
} else if (ch === 41 || ch === 93 || ch === 125) {
if (s.stack && s.stack.closer === ch) {
visit(base + i, s.stack.depth);
s.stack = s.stack.prev;
if ((s.stack?.depth ?? -1) < untilBelow) {
s.pos = base + i + 1;
return;
}
} else visit(base + i, -1);
}
i++;
}
s.pos = base + i;
const last = cps[cps.length - 1];
if (s.pos >= (last ? last.pos : 0) + CHUNK && s.pos <= doc.length) cps.push({ ...s });
}
}
}
/** The syntax tree when the editor has a language, else null (lexical scanning). */
function treeOf(state: EditorState): Tree | null {
const tree = syntaxTree(state);
return tree.length || state.doc.length === 0 ? tree : null;
}
/** In code at `i`: enters a comment / string and returns the index after its opening token, else `i`. */
function lexEnter(text: string, i: number, s: ScanState, c: CommentTokens): number {
const ch = text[i]!;
if (c.line && text.startsWith(c.line, i)) {
s.mode = 1;
return i + c.line.length;
}
if (c.block && text.startsWith(c.block.open, i)) {
s.mode = 2;
return i + c.block.open.length;
}
if (ch === '"' || ch === "`" || (ch === "'" && quoteStartsString(text, i))) {
s.mode = 3;
s.quote = ch;
return i + 1;
}
return i;
}
/** `'` starts a string unless it ends a word (Haskell's `x'`) or has no partner on the line (Rust lifetimes). */
function quoteStartsString(text: string, i: number): boolean {
if (i > 0 && /[\w]/.test(text[i - 1]!)) return false;
const nl = text.indexOf("\n", i + 1);
const close = text.indexOf("'", i + 1);
return close >= 0 && (nl < 0 || close < nl);
}
/** Inside a comment / string at `i`: the index after its end (back in code), else `stop` (still inside). */
function lexSkip(text: string, i: number, stop: number, s: ScanState, c: CommentTokens): number {
if (s.mode === 1 || s.mode === 2) {
const close = s.mode === 1 ? "\n" : (c.block?.close ?? "\n");
const at = text.indexOf(close, i);
if (at < 0 || at >= stop) return stop;
s.mode = 0;
return at + close.length;
}
for (; i < stop; i++) {
const ch = text[i];
if (ch === "\\") i++;
else if (ch === s.quote || (ch === "\n" && s.quote !== "`")) {
s.mode = 0;
return i + 1;
}
}
return i;
}
/** Class of a bracket at `depth` (-1: unmatched); the palette repeats every `BRACKET_COLORS` levels. */
export function bracketClass(depth: number): string {
return depth < 0 ? "cm-rainbow-bracket-unmatched" : `cm-rainbow-bracket-${(depth % BRACKET_COLORS) + 1}`;
}
const markCache = new Map<string, Decoration>();
function markFor(depth: number): Decoration {
const cls = bracketClass(depth);
let m = markCache.get(cls);
if (!m) markCache.set(cls, (m = Decoration.mark({ class: cls })));
return m;
}
function build(view: EditorView, scanner: BracketScanner): DecorationSet {
const { from, to } = view.viewport;
return Decoration.set(
scanner.marks(view.state, from, to).map((m) => markFor(m.depth).range(m.from, m.from + 1)),
true,
);
}
const plugin = ViewPlugin.fromClass(
class {
scanner = new BracketScanner();
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = build(view, this.scanner);
}
update(u: ViewUpdate) {
if (u.docChanged) {
let first = Infinity;
u.changes.iterChangedRanges((fromA) => (first = Math.min(first, fromA)));
this.scanner.invalidate(first);
}
if (u.docChanged || u.viewportChanged || syntaxTree(u.startState) !== syntaxTree(u.state))
this.decorations = build(u.view, this.scanner);
}
},
{ decorations: (v) => v.decorations },
);
/** Rainbow brackets for one editor (Settings › Editor › Color Scheme › Rainbow brackets). */
export function rainbowBrackets(): Extension {
// Above the syntax highlighter so the bracket colour is the innermost span.
return Prec.high(plugin);
}
+2
View File
@@ -36,6 +36,7 @@ import { changeLane, overviewLane } from "./lanes";
import { foldingExtension } from "./folding";
import { syntaxHighlightField } from "./syntaxHighlight";
import { indentGuides } from "./indentGuides";
import { rainbowBrackets } from "./rainbowBrackets";
import { stickyMaxLines, stickyScroll } from "./stickyScroll";
import { findExtension } from "./find";
import { codeLensExtension } from "./codeLens";
@@ -204,6 +205,7 @@ export function heavyExtension(docLength: number, settings?: EditorSettings): Ex
return [
prefs.codeEditing.highlightBraces ? bracketMatching({ renderMatch }) : [],
prefs.appearance.indentGuides ? indentGuides : [],
prefs.rainbowBrackets ? rainbowBrackets() : [],
prefs.codeEditing.highlightSelection ? highlightSelectionMatches({ minSelectionLength: 2, maxMatches: 500 }) : [],
];
}
+14
View File
@@ -6,6 +6,7 @@
* ones are read through `--editor-*` locals with fallbacks to the base tokens.
*/
import { EditorView } from "@codemirror/view";
import { BRACKET_COLORS } from "./rainbowBrackets";
export const openideaTheme = EditorView.theme({
"&": {
@@ -158,6 +159,19 @@ export const openideaTheme = EditorView.theme({
backgroundPosition: "6px 0",
},
// Rainbow brackets (cm/rainbowBrackets.ts); `*` too: the syntax highlighter's span sits inside and would win.
...Object.fromEntries(
Array.from({ length: BRACKET_COLORS }, (_, i) => [
`.cm-rainbow-bracket-${i + 1}, .cm-rainbow-bracket-${i + 1} *`,
{
color: `var(--syn-bracket-${i + 1})`,
fontWeight: `var(--syn-bracket-${i + 1}-bold, inherit)`,
fontStyle: `var(--syn-bracket-${i + 1}-italic, inherit)`,
},
]),
),
".cm-rainbow-bracket-unmatched, .cm-rainbow-bracket-unmatched *": { color: "var(--syn-invalid)" },
// Whitespace rendering
".cm-highlightSpace": {
backgroundImage: "radial-gradient(circle at 50% 55%, var(--fg-whitespace, var(--fg-faint)) 11%, transparent 12%)",
+61 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { Text } from "@codemirror/state";
import type { CodeAction } from "@/bindings";
import { semanticRanges } from "./semantic";
import { RAINBOW_COLORS, rainbowIndex, semanticRanges } from "./semantic";
import { lineErrorsAwayFromCaret, sortCodeActions } from "./intentions";
import { splitSignature } from "./signature";
import { dedupeLocations, usagesStatusText } from "./navigation";
@@ -28,6 +28,66 @@ describe("semanticRanges", () => {
});
});
describe("semantic highlighting (rainbow identifiers)", () => {
// fn f(total: u32, self) { let count = total; static S; obj.field; count }
const doc = Text.of(["fn f(total, self) { let count = total; S; obj.field; count + total }"]);
const tokens = {
tokenTypes: ["variable", "parameter", "property", "selfParameter"],
tokenModifiers: ["static", "declaration"],
data: [
[0, 5, 5, 1, 0b10], // total (parameter declaration)
[0, 12, 4, 3, 0], // self
[0, 24, 5, 0, 0b10], // count
[0, 32, 5, 1, 0], // total
[0, 39, 1, 0, 0b01], // S (static)
[0, 42, 3, 0, 0], // obj
[0, 46, 5, 2, 0], // field
[0, 53, 5, 0, 0], // count
[0, 61, 5, 1, 0], // total
].flat(),
};
const rainbow = (r: { className: string }) => /cm-sem-rainbow-(\d)/.exec(r.className)?.[1] ?? null;
it("hashes names to a stable palette index", () => {
expect(rainbowIndex("count")).toBe(rainbowIndex("count"));
const all = ["a", "b", "count", "total", "index", "value", "name", "item", "result", "i", "j", "k"].map(rainbowIndex);
for (const n of all) expect(n >= 1 && n <= RAINBOW_COLORS).toBe(true);
expect(new Set(all).size).toBeGreaterThan(2);
});
it("colours locals and parameters by name, only when enabled", () => {
expect(semanticRanges(doc, tokens).some((r) => rainbow(r))).toBe(false);
const ranges = semanticRanges(doc, tokens, true);
const byText = new Map<string, Set<string | null>>();
for (const r of ranges) {
const name = doc.sliceString(r.from, r.to);
byText.set(name, (byText.get(name) ?? new Set()).add(rainbow(r)));
}
expect(byText.get("total")).toEqual(new Set([String(rainbowIndex("total"))]));
expect(byText.get("count")).toEqual(new Set([String(rainbowIndex("count"))]));
expect(byText.get("obj")).toEqual(new Set([String(rainbowIndex("obj"))]));
// Parameters keep their own class too; self, statics and fields get no rainbow colour.
expect(ranges.find((r) => r.from === 5)!.className).toBe(`cm-sem-parameter cm-sem-declaration cm-sem-rainbow-${rainbowIndex("total")}`);
expect(byText.get("self")).toEqual(new Set([null]));
expect(byText.get("S")).toEqual(new Set([null]));
expect(byText.get("field")).toEqual(new Set([null]));
});
it("leaves module-level variables plain when the server marks locals", () => {
const ts = Text.of(["let top = 1; function f(p) { let x = top + p; }"]);
const r = semanticRanges(
ts,
{
tokenTypes: ["variable", "parameter"],
tokenModifiers: ["declaration", "local"],
data: [0, 4, 3, 0, 0b01, 0, 24, 1, 1, 0b11, 0, 33, 1, 0, 0b11, 0, 37, 3, 0, 0, 0, 43, 1, 1, 0b10],
},
true,
);
expect(r.filter((x) => x.className.includes("rainbow")).map((x) => ts.sliceString(x.from, x.to))).toEqual(["p", "x", "p"]);
});
});
describe("sortCodeActions", () => {
const a = (title: string, kind: string | null, isPreferred = false, disabledReason: string | null = null): CodeAction => ({
title,
+59 -7
View File
@@ -3,10 +3,16 @@
* `cm-sem-*` classes (styled with the --syn-* tokens) layered over the Lezer
* highlighting. Refreshed after edits (debounced); in between, existing marks
* are mapped through the changes.
*
* Semantic highlighting (Color Scheme › Language Defaults, IDEA's rainbow
* identifiers): local variables and parameters additionally get
* `cm-sem-rainbow-<n>`, `n` derived from the name, so one name keeps one
* colour (`--syn-rainbow-<n>`) throughout the file.
*/
import { Prec, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view";
import { commands, type SemanticTokens } from "@/platform/ipc";
import { editorPrefs } from "@/platform/editorSettings";
import { debounce, docOf, isUnavailable, lspRequest } from "./lsp";
import { onLspRefresh, serverBackoff } from "./availability";
@@ -50,6 +56,23 @@ const MODIFIER_CLASS: Record<string, string> = {
defaultLibrary: "builtin",
};
/** Number of `--syn-rainbow-<n>` colours (IDEA's five semantic highlighting colours). */
export const RAINBOW_COLORS = 5;
/** Stable 1-based palette index of an identifier (FNV-1a over its UTF-16 units). */
export function rainbowIndex(name: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < name.length; i++) {
h ^= name.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return ((h >>> 0) % RAINBOW_COLORS) + 1;
}
/** Locals and parameters only: no fields, statics, constants, library or `self` names. */
const RAINBOW_TYPES = new Set(["variable", "parameter"]);
const NOT_LOCAL = new Set(["static", "constant", "defaultLibrary", "global", "classScope", "fileScope"]);
export interface SemanticRange {
from: number;
to: number;
@@ -57,9 +80,11 @@ export interface SemanticRange {
}
/** Decodes absolute `[line, col, len, type, modifiers]` quintuples into styled ranges. */
export function semanticRanges(doc: Text, tokens: SemanticTokens): SemanticRange[] {
export function semanticRanges(doc: Text, tokens: SemanticTokens, rainbow = false): SemanticRange[] {
const out: SemanticRange[] = [];
const { data, tokenTypes, tokenModifiers } = tokens;
// Servers with a `local` modifier (TypeScript) tell module-level variables apart.
const marksLocals = tokenModifiers.includes("local");
for (let i = 0; i + 4 < data.length; i += 5) {
const line = data[i]!,
col = data[i + 1]!,
@@ -77,11 +102,19 @@ export function semanticRanges(doc: Text, tokens: SemanticTokens): SemanticRange
if (constant && (type === "variable" || type === "property")) base = "constant";
if (base && base !== "variable") classes.push(`cm-sem-${base}`);
for (const n of names) if (MODIFIER_CLASS[n]) classes.push(`cm-sem-${MODIFIER_CLASS[n]}`);
if (!classes.length) continue;
const local =
rainbow &&
!constant &&
RAINBOW_TYPES.has(type) &&
!names.some((n) => NOT_LOCAL.has(n)) &&
(type === "parameter" || !marksLocals || names.includes("local"));
if (!classes.length && !local) continue;
const l = doc.line(line + 1);
const from = l.from + Math.min(col, l.length);
const to = Math.min(from + len, l.to);
if (from < to) out.push({ from, to, className: classes.join(" ") });
if (from >= to) continue;
if (local) classes.push(`cm-sem-rainbow-${rainbowIndex(doc.sliceString(from, to))}`);
out.push({ from, to, className: classes.join(" ") });
}
return out.sort((a, b) => a.from - b.from || a.to - b.to);
}
@@ -107,12 +140,33 @@ const semanticPlugin = ViewPlugin.fromClass(
class {
private refresh = debounce(() => void this.run(), 600);
private offReady: () => void;
private rainbow = editorPrefs().semanticHighlighting;
/** Last answer, re-decorated without a request when Semantic highlighting is toggled. */
private last: { doc: Text; tokens: SemanticTokens | null } | null = null;
private destroyed = false;
constructor(private view: EditorView) {
this.refresh();
this.offReady = onLspRefresh("semanticTokens", () => this.refresh());
}
update(u: ViewUpdate) {
if (u.docChanged) this.refresh();
const rainbow = editorPrefs().semanticHighlighting;
if (rainbow === this.rainbow) return;
this.rainbow = rainbow;
const last = this.last;
// Settings arrive as a compartment reconfiguration; no dispatching inside an update.
if (last && last.doc === u.state.doc)
queueMicrotask(() => {
if (!this.destroyed && this.view.state.doc === last.doc) this.decorate(last.doc, last.tokens);
});
else this.refresh();
}
decorate(doc: Text, tokens: SemanticTokens | null) {
this.last = { doc, tokens };
const ranges = tokens ? semanticRanges(doc, tokens, this.rainbow) : [];
this.view.dispatch({
effects: setSemantic.of(Decoration.set(ranges.map((r) => markFor(r.className).range(r.from, r.to)), true)),
});
}
async run() {
const view = this.view;
@@ -122,15 +176,13 @@ const semanticPlugin = ViewPlugin.fromClass(
try {
const tokens = await lspRequest(() => commands.lspSemanticTokens(doc.doc));
if (view.state.doc !== state.doc) return this.refresh();
const ranges = tokens ? semanticRanges(state.doc, tokens) : [];
view.dispatch({
effects: setSemantic.of(Decoration.set(ranges.map((r) => markFor(r.className).range(r.from, r.to)), true)),
});
this.decorate(state.doc, tokens);
} catch (e) {
if (isUnavailable(e)) serverBackoff.mark(doc.doc);
}
}
destroy() {
this.destroyed = true;
this.refresh.cancel();
this.offReady();
}
+13
View File
@@ -1,5 +1,6 @@
/** Editor-side styling for LSP features (CodeMirror theme; tokens only). */
import { EditorView } from "@codemirror/view";
import { RAINBOW_COLORS } from "./semantic";
const tooltipSurface = {
backgroundColor: "var(--bg-popup)",
@@ -350,6 +351,18 @@ export const lspTheme = EditorView.theme({
".cm-sem-static": { fontStyle: "italic" },
".cm-sem-mutable": { textDecoration: "underline", textDecorationColor: "var(--fg-faint)", textUnderlineOffset: "3px" },
".cm-sem-deprecated": { textDecoration: "line-through" },
// Semantic highlighting: after the base classes so a parameter's colour gives way, and over the
// syntax highlighter's span inside.
...Object.fromEntries(
Array.from({ length: RAINBOW_COLORS }, (_, i) => [
`.cm-sem-rainbow-${i + 1}, .cm-sem-rainbow-${i + 1} *`,
{
color: `var(--syn-rainbow-${i + 1})`,
fontWeight: `var(--syn-rainbow-${i + 1}-bold, inherit)`,
fontStyle: `var(--syn-rainbow-${i + 1}-italic, inherit)`,
},
]),
),
...markdown,
});
@@ -354,9 +354,27 @@ export const COLOR_SCHEME_PAGE = editorPage({
"background",
"bold",
"italic",
"rainbow",
...SCHEMES.map((s) => s.name),
],
sections: [
{ items: [{ t: "custom", label: "Scheme", keywords: ATTRIBUTES.map((a) => a.name), component: ColorSchemePage }] },
{
title: "Language Defaults",
items: [
{
t: "check",
key: "editor.semanticHighlighting",
label: "Semantic highlighting",
hint: "Local variables and parameters get a colour derived from their name (needs a language server).",
},
{
t: "check",
key: "editor.rainbowBrackets",
label: "Rainbow brackets",
hint: "Color (), [] and {} by nesting depth; unmatched brackets use the error color.",
},
],
},
],
});
+2
View File
@@ -94,6 +94,8 @@ export const ATTRIBUTES: readonly AttributeDef[] = [
syn("operator", "Braces and Operators", [LD], "punct"),
syn("markupTag", "Markup › Tag", [LD], "tag"),
syn("markupAttribute", "Markup › Attribute", [LD], "attribute"),
...[1, 2, 3, 4, 5].map((n) => syn(`semantic${n}`, `Semantic highlighting › Color ${n}`, [LD], `rainbow-${n}`)),
...[1, 2, 3].map((n) => syn(`bracket${n}`, `Rainbow brackets › Level ${n}`, [LD], `bracket-${n}`)),
syn("pySelf", "Self parameter", ["Python"], "self"),
syn("pyKeywordArg", "Keyword argument", ["Python"], "keyword-arg"),
+4
View File
@@ -52,6 +52,10 @@ export function lineCommentStyleFor(prefs: EditorExtras, language: string | unde
export const EDITOR_EXTRA_DEFAULTS = {
colorOverrides: {} as Record<string, Record<string, TextAttributesOverride>>,
/** Color Scheme › Language Defaults › Semantic highlighting (rainbow locals / parameters). */
semanticHighlighting: false,
/** Color Scheme › Language Defaults › Rainbow brackets. */
rainbowBrackets: false,
ligatures: false,
fallbackFont: "",
/** Typography Settings › Main / Bold font weight (CSS weights, IDEA's Regular / Bold). */
+24
View File
@@ -115,6 +115,14 @@ html {
--syn-markup-code: #6aab73;
--syn-invalid: #f75464;
--syn-inline-value: #868a91;
--syn-rainbow-1: #6fafbd;
--syn-rainbow-2: #d79b6e;
--syn-rainbow-3: #9fbf6b;
--syn-rainbow-4: #c98bc6;
--syn-rainbow-5: #8e9cf0;
--syn-bracket-1: #e8c25b;
--syn-bracket-2: #d78bd6;
--syn-bracket-3: #4ea7f0;
/* editor (scheme colours) */
--fg-editor: #bcbec4;
@@ -449,6 +457,14 @@ html {
--syn-markup-code: #067d17;
--syn-invalid: #f50000;
--syn-inline-value: #8c8c8c;
--syn-rainbow-1: #9b3b6a;
--syn-rainbow-2: #114d77;
--syn-rainbow-3: #9c6a2c;
--syn-rainbow-4: #005910;
--syn-rainbow-5: #bc5150;
--syn-bracket-1: #0431fa;
--syn-bracket-2: #319331;
--syn-bracket-3: #7b3814;
/* editor */
--fg-editor: #080808;
@@ -670,6 +686,14 @@ html {
--syn-markup-code: #8ccc94;
--syn-invalid: #ff8a95;
--syn-inline-value: #a5a8ae;
--syn-rainbow-1: #8fd0de;
--syn-rainbow-2: #f0b88a;
--syn-rainbow-3: #bedc8a;
--syn-rainbow-4: #e8a8e4;
--syn-rainbow-5: #b0bcff;
--syn-bracket-1: #ffd700;
--syn-bracket-2: #ee90ee;
--syn-bracket-3: #87cefa;
--fg-editor: #e3e4e8;
--caret: #ffffff;
@@ -35,6 +35,14 @@
"--syn-markup-code": "#9ccc7a",
"--syn-invalid": "#ff6b7a",
"--syn-inline-value": "#6b7a8f",
"--syn-rainbow-1": "#7fd1c4",
"--syn-rainbow-2": "#e0a46e",
"--syn-rainbow-3": "#b9a3f0",
"--syn-rainbow-4": "#9ccc7a",
"--syn-rainbow-5": "#e58fa3",
"--syn-bracket-1": "#f2d07a",
"--syn-bracket-2": "#d7a0e0",
"--syn-bracket-3": "#6fc3df",
// Editor
"--fg-editor": "#d9dfe8",