diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index d3f31d929..8732351d6 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -289,6 +289,11 @@ pub struct EditorSettings { pub color_scheme: String, /// Color Scheme edits: scheme id → attribute id → override. pub color_overrides: BTreeMap>, + /// 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, diff --git a/docs/plugins/themes.md b/docs/plugins/themes.md index 3e4e550cc..cde05ac35 100644 --- a/docs/plugins/themes.md +++ b/docs/plugins/themes.md @@ -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. -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) diff --git a/scripts/theme/intellij.ts b/scripts/theme/intellij.ts index c9b35e162..1fb828b95 100644 --- a/scripts/theme/intellij.ts +++ b/scripts/theme/intellij.ts @@ -135,6 +135,11 @@ export const ICLS_ATTRIBUTES: Record> = { 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"]], diff --git a/src/bindings.ts b/src/bindings.ts index 853cb8822..c76ff5c57 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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, diff --git a/src/editor/core/actions.ts b/src/editor/core/actions.ts index f03ac4f82..e2c9727ae 100644 --- a/src/editor/core/actions.ts +++ b/src/editor/core/actions.ts @@ -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 { + 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)), diff --git a/src/editor/core/cm/rainbowBrackets.test.ts b/src/editor/core/cm/rainbowBrackets.test.ts new file mode 100644 index 000000000..9b325d155 --- /dev/null +++ b/src/editor/core/cm/rainbowBrackets.test.ts @@ -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", + ]); + }); +}); diff --git a/src/editor/core/cm/rainbowBrackets.ts b/src/editor/core/cm/rainbowBrackets.ts new file mode 100644 index 000000000..3cc99e20a --- /dev/null +++ b/src/editor/core/cm/rainbowBrackets.ts @@ -0,0 +1,309 @@ +/** + * Rainbow brackets (VS Code's bracket pair colorization): `()`, `[]`, `{}` + * coloured by nesting depth (`--syn-bracket-`, 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; +type NodeType = Tree["type"]; + +/** Number of `--syn-bracket-` 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 = { 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(); +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", 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(); + 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(); +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); +} diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index 4a47ce40b..b25a4184f 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -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 }) : [], ]; } diff --git a/src/editor/core/cm/theme.ts b/src/editor/core/cm/theme.ts index a0d46311c..f053ee393 100644 --- a/src/editor/core/cm/theme.ts +++ b/src/editor/core/cm/theme.ts @@ -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%)", diff --git a/src/editor/lsp/features.test.ts b/src/editor/lsp/features.test.ts index 1f40be531..1f23db661 100644 --- a/src/editor/lsp/features.test.ts +++ b/src/editor/lsp/features.test.ts @@ -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>(); + 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, diff --git a/src/editor/lsp/semantic.ts b/src/editor/lsp/semantic.ts index 2a11eeee9..5ab2a19fb 100644 --- a/src/editor/lsp/semantic.ts +++ b/src/editor/lsp/semantic.ts @@ -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` derived from the name, so one name keeps one + * colour (`--syn-rainbow-`) 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 = { defaultLibrary: "builtin", }; +/** Number of `--syn-rainbow-` 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(); } diff --git a/src/editor/lsp/theme.ts b/src/editor/lsp/theme.ts index 1fb6d96d3..b04640910 100644 --- a/src/editor/lsp/theme.ts +++ b/src/editor/lsp/theme.ts @@ -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, }); diff --git a/src/features/settings/pages/editor/colorScheme.tsx b/src/features/settings/pages/editor/colorScheme.tsx index 8eecc45dd..05bc9fe91 100644 --- a/src/features/settings/pages/editor/colorScheme.tsx +++ b/src/features/settings/pages/editor/colorScheme.tsx @@ -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.", + }, + ], + }, ], }); diff --git a/src/platform/colorScheme.ts b/src/platform/colorScheme.ts index 5204fd523..a0f829489 100644 --- a/src/platform/colorScheme.ts +++ b/src/platform/colorScheme.ts @@ -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"), diff --git a/src/platform/editorSettings.ts b/src/platform/editorSettings.ts index 04dd6bf51..a51d2fad3 100644 --- a/src/platform/editorSettings.ts +++ b/src/platform/editorSettings.ts @@ -52,6 +52,10 @@ export function lineCommentStyleFor(prefs: EditorExtras, language: string | unde export const EDITOR_EXTRA_DEFAULTS = { colorOverrides: {} as Record>, + /** 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). */ diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 6bc00f49d..4d0307b67 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -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; diff --git a/templates/theme-plugin/themes/harbor-night.scheme.json b/templates/theme-plugin/themes/harbor-night.scheme.json index 2a67c00c5..bfc824707 100644 --- a/templates/theme-plugin/themes/harbor-night.scheme.json +++ b/templates/theme-plugin/themes/harbor-night.scheme.json @@ -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",