Files
openidea/src/platform/colorScheme.ts
T

267 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Editor colour schemes (Settings › Editor › Color Scheme). A scheme is the
* editor part of a theme's tokens (`--syn-*`, editor surfaces, gutter,
* search, diagnostics), read from `styles/tokens.css` so the scheme list never
* drifts from the themes. Choosing a scheme other than the theme's own, or
* editing an attribute, injects those variables into one stylesheet.
*
* Attribute ids are stable (they key `editor.colorOverrides[scheme][id]`).
*/
import tokensCss from "@/styles/tokens.css?raw";
import type { TextAttributesOverride } from "./editorSettings";
export type SchemeId = "light" | "dark" | "dark-hc";
export const SCHEMES: ReadonlyArray<{ id: SchemeId; name: string }> = [
{ id: "light", name: "Islands Light" },
{ id: "dark", name: "Islands Dark" },
{ id: "dark-hc", name: "High Contrast" },
];
export interface AttributeDef {
id: string;
name: string;
/** Tree path below the root, e.g. ["Language Defaults", "Comments"]. */
path: string[];
/** CSS variable of the foreground / background / effect colour, if editable. */
fg?: string;
bg?: string;
effect?: string;
/** Syntax token: bold / italic / background / effect come from `--syn-<token>-*`. */
token?: string;
}
const syn = (id: string, name: string, path: string[], token: string): AttributeDef => ({
id,
name,
path,
token,
fg: `--syn-${token}`,
});
const LD = "Language Defaults";
/** IDEA-style attribute tree: General, Language Defaults, then languages. */
export const ATTRIBUTES: readonly AttributeDef[] = [
{ id: "text", name: "Default text", path: ["General", "Text"], fg: "--fg-editor", bg: "--bg-editor" },
{ id: "caret", name: "Caret", path: ["General", "Editor"], fg: "--caret" },
{ id: "caretRow", name: "Caret row", path: ["General", "Editor"], bg: "--bg-current-line" },
{ id: "selection", name: "Selection background", path: ["General", "Editor"], bg: "--bg-editor-selection" },
{ id: "selectionInactive", name: "Selection background (inactive)", path: ["General", "Editor"], bg: "--bg-editor-selection-inactive" },
{ id: "whitespaces", name: "Whitespaces", path: ["General", "Editor"], fg: "--fg-whitespace" },
{ id: "indentGuide", name: "Indent guide", path: ["General", "Editor"], fg: "--border-indent-guide" },
{ id: "rightMargin", name: "Hard wrap guide", path: ["General", "Editor"], fg: "--border-right-margin" },
{ id: "methodSeparator", name: "Method separator color", path: ["General", "Editor"], fg: "--border-method-separator" },
{ id: "lineNumber", name: "Line number", path: ["General", "Code"], fg: "--fg-line-number" },
{ id: "lineNumberCaret", name: "Line number on caret row", path: ["General", "Code"], fg: "--fg-line-number-current" },
{ id: "gutter", name: "Gutter background", path: ["General", "Code"], bg: "--bg-editor-gutter" },
{ id: "matchedBrace", name: "Matched brace", path: ["General", "Code"], bg: "--bg-matched-brace" },
{ id: "identifierUnderCaret", name: "Identifier under caret", path: ["General", "Code"], bg: "--bg-identifier-under-caret" },
{
id: "identifierUnderCaretWrite",
name: "Identifier under caret (write)",
path: ["General", "Code"],
bg: "--bg-identifier-under-caret-write",
},
{ id: "inlay", name: "Inlay hints", path: ["General", "Code"], fg: "--fg-inlay", bg: "--bg-inlay" },
{ id: "folded", name: "Folded text", path: ["General", "Code"], fg: "--fg-fold-placeholder", bg: "--bg-fold-placeholder" },
{ id: "searchResult", name: "Search result", path: ["General", "Search Results"], bg: "--bg-search-match" },
{ id: "searchResultCurrent", name: "Current search result", path: ["General", "Search Results"], bg: "--bg-search-match-current" },
{ id: "error", name: "Error", path: ["General", "Errors and Warnings"], effect: "--wave-error" },
{ id: "warning", name: "Warning", path: ["General", "Errors and Warnings"], effect: "--wave-warning" },
{ id: "weakWarning", name: "Weak warning", path: ["General", "Errors and Warnings"], fg: "--fg-weak-warning" },
{ id: "unused", name: "Unused symbol", path: ["General", "Errors and Warnings"], fg: "--fg-unused" },
syn("invalid", "Bad character", ["General", "Errors and Warnings"], "invalid"),
syn("todo", "TODO defaults", ["General", "Code"], "todo"),
{ id: "inlineValue", name: "Inline debugger values", path: ["Debugger"], fg: "--syn-inline-value" },
{ id: "breakpointLine", name: "Breakpoint line", path: ["Debugger"], bg: "--bg-breakpoint-line" },
syn("keyword", "Keyword", [LD], "keyword"),
syn("identifier", "Identifiers › Local variable", [LD], "variable"),
syn("parameter", "Identifiers › Parameter", [LD], "param"),
syn("constant", "Identifiers › Constant", [LD], "constant"),
syn("field", "Identifiers › Instance field", [LD], "property"),
syn("functionDeclaration", "Identifiers › Function declaration", [LD], "function"),
syn("functionCall", "Identifiers › Function call", [LD], "function-call"),
syn("predefined", "Identifiers › Predefined symbol", [LD], "builtin"),
syn("className", "Classes › Class name", [LD], "type"),
syn("number", "Number", [LD], "number"),
syn("string", "String › String text", [LD], "string"),
syn("escape", "String › Valid escape sequence", [LD], "escape"),
syn("lineComment", "Comments › Line comment", [LD], "comment"),
syn("docComment", "Comments › Doc comment", [LD], "doc-comment"),
syn("metadata", "Metadata", [LD], "decorator"),
syn("operator", "Braces and Operators", [LD], "punct"),
syn("markupTag", "Markup › Tag", [LD], "tag"),
syn("markupAttribute", "Markup › Attribute", [LD], "attribute"),
syn("pySelf", "Self parameter", ["Python"], "self"),
syn("pyKeywordArg", "Keyword argument", ["Python"], "keyword-arg"),
syn("pyDocstring", "Docstring", ["Python"], "docstring"),
syn("pyBuiltin", "Built-in name", ["Python"], "predefined"),
syn("pyDecorator", "Decorator", ["Python"], "annotation"),
syn("mdHeading", "Heading", ["Markdown"], "markup-heading"),
syn("mdCode", "Code span", ["Markdown"], "markup-code"),
syn("mdLink", "Link", ["Markdown"], "link"),
syn("regexp", "Regular expression", ["RegExp"], "regexp"),
];
// --- scheme base values ------------------------------------------------------
/** Variables a scheme sets: every attribute variable plus the editor tokens derived from them. */
const SCHEME_VARS = new Set<string>([
...ATTRIBUTES.flatMap((a) => [a.fg, a.bg, a.effect].filter((v): v is string => !!v)),
"--syn-comment-style",
"--editor-error-decoration",
"--editor-warning-decoration",
"--editor-warning-background",
"--border-gutter",
"--border-indent-guide-active",
"--fg-fold-marker",
"--bg-editor-inline",
"--fg-completion-match",
"--fg-search-match",
]);
type Vars = Map<string, string>;
/** Top-level `selector { decls }` blocks of a stylesheet (nested at-rules skipped). */
function blocks(css: string): Array<{ selectors: string[]; decls: Vars }> {
const out: Array<{ selectors: string[]; decls: Vars }> = [];
const text = css.replace(/\/\*[\s\S]*?\*\//g, "");
let depth = 0;
let start = 0;
let selector = "";
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === "{") {
if (depth === 0) {
selector = text.slice(start, i).trim();
start = i + 1;
}
depth++;
} else if (ch === "}") {
depth--;
if (depth === 0) {
if (!selector.startsWith("@")) {
const decls: Vars = new Map();
for (const m of text.slice(start, i).matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) decls.set(m[1]!, m[2]!.trim());
out.push({ selectors: selector.split(",").map((s) => s.trim()), decls });
}
start = i + 1;
}
}
}
return out;
}
function themeSelectors(id: SchemeId): string[] {
if (id === "light") return [':root[data-theme="light"]'];
if (id === "dark-hc") return [':root[data-theme="dark-hc"]', ':root[data-theme="dark"][data-contrast="high"]'];
return [':root[data-theme="dark"]'];
}
let source: string = tokensCss;
let parsed: ReturnType<typeof blocks> | null = null;
const resolvedCache = new Map<SchemeId, Vars>();
/** Replaces the token source (tests: CSS `?raw` imports are empty under vitest). */
export function _setSchemeSource(css: string) {
source = css;
parsed = null;
resolvedCache.clear();
}
/** All tokens of a theme with `var()` references resolved (so they survive under another theme). */
export function schemeTokens(id: SchemeId, css: string = source): Vars {
const cached = css === source ? resolvedCache.get(id) : undefined;
if (cached) return cached;
const all = css === source ? (parsed ??= blocks(css)) : blocks(css);
const own = themeSelectors(id);
const merged: Vars = new Map();
for (const b of all) if (b.selectors.includes(":root")) for (const [k, v] of b.decls) merged.set(k, v);
for (const b of all) if (b.selectors.some((s) => own.includes(s))) for (const [k, v] of b.decls) merged.set(k, v);
const resolve = (value: string, depth = 0): string =>
depth > 8
? value
: value.replace(/var\((--[\w-]+)(?:\s*,\s*([^()]*(?:\([^()]*\))?[^()]*))?\)/g, (_m, name: string, fallback?: string) => {
const v = merged.get(name);
return v !== undefined ? resolve(v, depth + 1) : fallback !== undefined ? resolve(fallback.trim(), depth + 1) : `var(${name})`;
});
const out: Vars = new Map();
for (const [k, v] of merged) out.set(k, resolve(v));
if (css === source) resolvedCache.set(id, out);
return out;
}
/** The scheme the editor shows for `setting` under the current theme. */
export function effectiveScheme(setting: string | undefined, root: HTMLElement | null = typeof document !== "undefined" ? document.documentElement : null): SchemeId {
if (setting === "light" || setting === "dark" || setting === "dark-hc") return setting;
return themeScheme(root);
}
/** Scheme belonging to the active IDE theme (`data-theme` / `data-contrast`). */
export function themeScheme(root: HTMLElement | null = typeof document !== "undefined" ? document.documentElement : null): SchemeId {
if (!root) return "light";
if (root.dataset.theme === "light") return "light";
return root.dataset.contrast === "high" || root.dataset.theme === "dark-hc" ? "dark-hc" : "dark";
}
/** Base value of an attribute variable in a scheme. */
export function schemeValue(id: SchemeId, cssVar: string): string | undefined {
return schemeTokens(id).get(cssVar);
}
const EFFECT_CSS: Record<string, (c: string) => string> = {
underline: (c) => `underline ${c}`,
wave: (c) => `underline wavy ${c}`,
strikeout: (c) => `line-through ${c}`,
none: () => "none",
};
/** Declarations for the overrides of one scheme. */
export function overrideDeclarations(overrides: Record<string, TextAttributesOverride> | undefined): Array<[string, string]> {
const out: Array<[string, string]> = [];
if (!overrides) return out;
for (const attr of ATTRIBUTES) {
const o = overrides[attr.id];
if (!o) continue;
if (o.foreground && attr.fg) out.push([attr.fg, o.foreground]);
if (o.background && attr.bg) out.push([attr.bg, o.background]);
if (o.effectColor && attr.effect) out.push([attr.effect, o.effectColor]);
if (attr.effect && o.effectColor) {
if (attr.id === "error") out.push(["--editor-error-decoration", `underline wavy ${o.effectColor}`]);
if (attr.id === "warning") out.push(["--editor-warning-decoration", `underline wavy ${o.effectColor}`]);
}
if (attr.token) {
const t = attr.token;
if (o.bold !== undefined) out.push([`--syn-${t}-bold`, o.bold ? "700" : "400"]);
if (o.italic !== undefined) {
out.push([`--syn-${t}-italic`, o.italic ? "italic" : "normal"]);
if (t === "comment") out.push(["--syn-comment-style", o.italic ? "italic" : "normal"]);
}
if (o.background) out.push([`--syn-${t}-bg`, o.background]);
if (o.effect) out.push([`--syn-${t}-effect`, EFFECT_CSS[o.effect]?.(o.effectColor ?? "currentColor") ?? "none"]);
}
}
return out;
}
const SAFE = /^[^;{}<>]*$/;
/** Stylesheet text for the chosen scheme and its overrides ("" when the theme's own is used unchanged). */
export function schemeCss(setting: string | undefined, overrides: Record<string, Record<string, TextAttributesOverride>> | undefined, root?: HTMLElement | null): string {
const id = effectiveScheme(setting, root);
const decls: Array<[string, string]> = [];
if (id !== themeScheme(root)) {
const tokens = schemeTokens(id);
for (const [k, v] of tokens) if (SCHEME_VARS.has(k) || k.startsWith("--syn-")) decls.push([k, v]);
// The editor surface follows the scheme, not the island colour of the UI theme.
const bg = tokens.get("--bg-editor") ?? tokens.get("--bg-island");
if (bg) decls.push(["--bg-editor", bg], ["--bg-editor-gutter", tokens.get("--bg-editor-gutter") ?? bg]);
}
decls.push(...overrideDeclarations(overrides?.[id]));
const body = decls.filter(([k, v]) => /^--[\w-]+$/.test(k) && SAFE.test(v)).map(([k, v]) => ` ${k}: ${v} !important;`);
return body.length ? `:root {\n${body.join("\n")}\n}\n` : "";
}