дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
feat(settings): import and export editor color schemes (#518)
The Color Scheme page only offered built-in schemes, so a scheme from another IDE could not be used. Import reads .icls files into named custom schemes in user settings, reports applied and skipped values, and rejects malformed files. Export writes the current scheme, replacing an existing file, and custom schemes can be deleted. Colours are validated before reaching a stylesheet, and project settings cannot define schemes. The Appearance selector and Quick Switch list custom schemes too. Fixes #513
Цей коміт міститься в:
1 джерело
92cbdd0cb8
коміт
bde19959b9
17 files changed
+950
-57
No files matched your search
@@ -0,0 +1,8 @@
|
||||
<scheme name="Daylight" version="142" parent_scheme="Default">
|
||||
<colors><option name="SELECTION_BACKGROUND" value="ccddff"/></colors>
|
||||
<attributes>
|
||||
<option name="TEXT"><value><option name="FOREGROUND" value="202030"/><option name="BACKGROUND" value="f8f8f0"/></value></option>
|
||||
<option name="KOTLIN_KEYWORD"><value><option name="FOREGROUND" value="663399"/></value></option>
|
||||
<option name="JAVA_STRING"><value><option name="FOREGROUND" value="006633"/></value></option>
|
||||
</attributes>
|
||||
</scheme>
|
||||
@@ -0,0 +1,21 @@
|
||||
<scheme name="Forest & Moss" version="142" parent_scheme="Darcula">
|
||||
<metaInfo><property name="ide">idea</property></metaInfo>
|
||||
<colors>
|
||||
<option name="CARET_COLOR" value="f0e080" />
|
||||
<option name="CARET_ROW_COLOR" value="243829" />
|
||||
<option name="SELECTION_BACKGROUND" value="406040" />
|
||||
<option name="GUTTER_BACKGROUND" value="142018" />
|
||||
<option name="LINE_NUMBERS_COLOR" value="708070" />
|
||||
<option name="UNKNOWN_COLOR" value="123456" />
|
||||
</colors>
|
||||
<attributes>
|
||||
<option name="TEXT"><value><option name="FOREGROUND" value="d8ebd8"/><option name="BACKGROUND" value="18281e"/></value></option>
|
||||
<option name="DEFAULT_KEYWORD"><value><option name="FOREGROUND" value="aadd88"/></value></option>
|
||||
<option name="JAVA_KEYWORD"><value><option name="FOREGROUND" value="99dd77"/><option name="FONT_TYPE" value="1"/></value></option>
|
||||
<option name="KOTLIN_FUNCTION_DECLARATION"><value><option name="FOREGROUND" value="e0c080"/><option name="FONT_TYPE" value="2"/></value></option>
|
||||
<option name="DEFAULT_STRING"><value><option name="FOREGROUND" value="88ddbb"/></value></option>
|
||||
<option name="SEARCH_RESULT_ATTRIBUTES"><value><option name="BACKGROUND" value="6a5530"/></value></option>
|
||||
<option name="ERRORS_ATTRIBUTES"><value><option name="EFFECT_COLOR" value="ff6655"/><option name="EFFECT_TYPE" value="1"/></value></option>
|
||||
<option name="UNKNOWN_LANGUAGE_TOKEN"><value><option name="FOREGROUND" value="123456"/></value></option>
|
||||
</attributes>
|
||||
</scheme>
|
||||
@@ -21,6 +21,7 @@ export const CLIENT_SETTINGS_KEYS = [
|
||||
"editor.boldFontWeight",
|
||||
"editor.colorScheme",
|
||||
"editor.colorOverrides",
|
||||
"editor.customSchemes",
|
||||
"editor.semanticHighlighting",
|
||||
"editor.rainbowBrackets",
|
||||
"editor.softWrap",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* Attribute ids are stable (they key `editor.colorOverrides[scheme][id]`).
|
||||
*/
|
||||
import tokensCss from "@/styles/tokens.css?raw";
|
||||
import type { TextAttributesOverride } from "./editorSettings";
|
||||
import type { CustomColorSchemes, TextAttributesOverride } from "./editorSettings";
|
||||
import { BUILTIN_THEMES, isBuiltinTheme, themeKind, type BuiltinThemeId } from "./themes";
|
||||
|
||||
export type SchemeId = BuiltinThemeId;
|
||||
@@ -253,8 +253,9 @@ export function schemeTokens(id: SchemeId, css: string = source): Vars {
|
||||
export function effectiveScheme(
|
||||
setting: string | undefined,
|
||||
root: HTMLElement | null = typeof document !== "undefined" ? document.documentElement : null,
|
||||
): SchemeId {
|
||||
if (isBuiltinTheme(setting)) return setting;
|
||||
customs: CustomColorSchemes = {},
|
||||
): string {
|
||||
if (isBuiltinTheme(setting) || (setting && Object.hasOwn(customs, setting))) return setting;
|
||||
return themeScheme(root);
|
||||
}
|
||||
|
||||
@@ -289,10 +290,10 @@ export function overrideDeclarations(
|
||||
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 (isHexColor(o.foreground) && attr.fg) out.push([attr.fg, o.foreground]);
|
||||
if (isHexColor(o.background) && attr.bg) out.push([attr.bg, o.background]);
|
||||
if (isHexColor(o.effectColor) && attr.effect) out.push([attr.effect, o.effectColor]);
|
||||
if (attr.effect && isHexColor(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}`]);
|
||||
}
|
||||
@@ -303,13 +304,58 @@ export function overrideDeclarations(
|
||||
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"]);
|
||||
if (isHexColor(o.background)) out.push([`--syn-${t}-bg`, o.background]);
|
||||
if (o.effect)
|
||||
out.push([
|
||||
`--syn-${t}-effect`,
|
||||
EFFECT_CSS[o.effect]?.(isHexColor(o.effectColor) ? o.effectColor : "currentColor") ?? "none",
|
||||
]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const isHexColor = (value: unknown): value is string =>
|
||||
typeof value === "string" && /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(value);
|
||||
|
||||
/** Validate persisted imports too: settings files are user editable. */
|
||||
export function customSchemes(raw: unknown): CustomColorSchemes {
|
||||
const out: CustomColorSchemes = {};
|
||||
if (!raw || typeof raw !== "object") return out;
|
||||
for (const [id, value] of Object.entries(raw)) {
|
||||
if (!/^custom-[\w-]+$/.test(id) || !value || typeof value !== "object") continue;
|
||||
const v = value as Record<string, unknown>;
|
||||
if (typeof v.name !== "string" || !v.name.trim() || (v.parent !== "light" && v.parent !== "dark")) continue;
|
||||
const attributes: Record<string, TextAttributesOverride> = {};
|
||||
if (v.attributes && typeof v.attributes === "object") {
|
||||
for (const attr of ATTRIBUTES) {
|
||||
const a = (v.attributes as Record<string, TextAttributesOverride>)[attr.id];
|
||||
if (!a || typeof a !== "object") continue;
|
||||
const safe: TextAttributesOverride = {};
|
||||
for (const key of ["foreground", "background", "effectColor"] as const)
|
||||
if (isHexColor(a[key])) safe[key] = a[key];
|
||||
for (const key of ["bold", "italic"] as const) if (typeof a[key] === "boolean") safe[key] = a[key];
|
||||
if (a.effect && Object.hasOwn(EFFECT_CSS, a.effect)) safe.effect = a.effect;
|
||||
attributes[attr.id] = safe;
|
||||
}
|
||||
}
|
||||
out[id] = { name: v.name, parent: v.parent, attributes };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function listSchemes(customs: CustomColorSchemes = {}) {
|
||||
return [...SCHEMES, ...Object.entries(customSchemes(customs)).map(([id, s]) => ({ id, name: s.name }))];
|
||||
}
|
||||
|
||||
export function resolvedSchemeTokens(id: string, customs: CustomColorSchemes = {}): Vars {
|
||||
const custom = customSchemes(customs)[id];
|
||||
const base = isBuiltinTheme(id) ? id : (custom?.parent ?? "light");
|
||||
const tokens = new Map(schemeTokens(base));
|
||||
for (const [key, value] of overrideDeclarations(custom?.attributes)) tokens.set(key, value);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
const SAFE = /^[^;{}<>]*$/;
|
||||
|
||||
/** Stylesheet text for the chosen scheme and its overrides ("" when the theme's own is used unchanged). */
|
||||
@@ -317,14 +363,16 @@ export function schemeCss(
|
||||
setting: string | undefined,
|
||||
overrides: Record<string, Record<string, TextAttributesOverride>> | undefined,
|
||||
root?: HTMLElement | null,
|
||||
customs: CustomColorSchemes = {},
|
||||
): string {
|
||||
const id = effectiveScheme(setting, root);
|
||||
const valid = customSchemes(customs);
|
||||
const id = effectiveScheme(setting, root, valid);
|
||||
const decls: Array<[string, string]> = [];
|
||||
// A plugin theme brings its own editor colours: an explicit scheme replaces them even on the same base.
|
||||
const el = root === undefined ? (typeof document !== "undefined" ? document.documentElement : null) : root;
|
||||
const pluginTheme = !!el?.dataset.pluginTheme && id === setting;
|
||||
if (id !== themeScheme(root) || pluginTheme) {
|
||||
const tokens = schemeTokens(id);
|
||||
const tokens = resolvedSchemeTokens(id, valid);
|
||||
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");
|
||||
|
||||
@@ -15,6 +15,14 @@ export interface TextAttributesOverride {
|
||||
effectColor?: string;
|
||||
}
|
||||
|
||||
export interface CustomColorScheme {
|
||||
name: string;
|
||||
parent: "dark" | "light";
|
||||
attributes: Record<string, TextAttributesOverride>;
|
||||
}
|
||||
|
||||
export type CustomColorSchemes = Record<string, CustomColorScheme>;
|
||||
|
||||
export interface LanguageCodeStyle {
|
||||
useTab?: boolean;
|
||||
tabSize?: number;
|
||||
@@ -55,6 +63,7 @@ export function lineCommentStyleFor(
|
||||
}
|
||||
|
||||
export const EDITOR_EXTRA_DEFAULTS = {
|
||||
customSchemes: {} as CustomColorSchemes,
|
||||
colorOverrides: {} as Record<string, Record<string, TextAttributesOverride>>,
|
||||
/** Color Scheme › Language Defaults › Semantic highlighting (rainbow locals / parameters). */
|
||||
semanticHighlighting: false,
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { _setSchemeSource, customSchemes, effectiveScheme, listSchemes, SCHEMES, schemeCss } from "./colorScheme";
|
||||
import { deleteScheme, exportIcls, importScheme, parseIcls } from "./icls";
|
||||
import { validateSettings } from "@/features/settings/model/schema";
|
||||
import { colorSchemeItems } from "@/workbench/quickSwitch";
|
||||
import { useProject } from "./project";
|
||||
import type { Settings } from "./ipc";
|
||||
import { isClientSetting } from "./clientSettings";
|
||||
|
||||
const fixture = (name: string) => readFileSync(`src/platform/__fixtures__/colorSchemes/${name}.icls`, "utf8");
|
||||
beforeAll(() => _setSchemeSource(readFileSync("src/styles/tokens.css", "utf8")));
|
||||
|
||||
describe(".icls schemes", () => {
|
||||
it("maps defaults, Java/Kotlin, editor surfaces, search and diagnostics with counts", () => {
|
||||
const { scheme, applied, skipped } = parseIcls(fixture("forest"));
|
||||
expect(scheme.name).toBe("Forest & Moss");
|
||||
expect(scheme.parent).toBe("dark");
|
||||
expect(scheme.attributes.keyword).toEqual({ foreground: "#99dd77", bold: true, italic: false });
|
||||
expect(scheme.attributes.functionDeclaration).toEqual({ foreground: "#e0c080", bold: false, italic: true });
|
||||
expect(scheme.attributes.text).toEqual({ foreground: "#d8ebd8", background: "#18281e" });
|
||||
expect(scheme.attributes.caret?.foreground).toBe("#f0e080");
|
||||
expect(scheme.attributes.selection?.background).toBe("#406040");
|
||||
expect(scheme.attributes.gutter?.background).toBe("#142018");
|
||||
expect(scheme.attributes.searchResult?.background).toBe("#6a5530");
|
||||
expect(scheme.attributes.error).toEqual({ effect: "wave", effectColor: "#ff6655" });
|
||||
expect([applied, skipped]).toEqual([16, 2]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"",
|
||||
"<scheme",
|
||||
'<scheme name="A"><attributes></scheme>',
|
||||
"<scheme/>",
|
||||
'<html name="x"/>',
|
||||
'<!DOCTYPE scheme [<!ENTITY a "red">]><scheme name="x"/>',
|
||||
])("rejects malformed XML without mutating schemes: %s", (xml) => {
|
||||
const customs = importScheme({}, parseIcls(fixture("forest")).scheme).customs;
|
||||
const before = structuredClone(customs);
|
||||
expect(() => parseIcls(xml)).toThrow("Invalid .icls color scheme");
|
||||
expect(customs).toEqual(before);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Darcula", "ffffff", "dark"],
|
||||
["Default", "000000", "light"],
|
||||
["Other", "101820", "dark"],
|
||||
["Other", "fefefe", "light"],
|
||||
["", "0", "dark"],
|
||||
])("maps parent %s and background %s to %s", (parent, bg, expected) => {
|
||||
expect(
|
||||
parseIcls(
|
||||
`<scheme name="Test" parent_scheme="${parent}"><attributes><option name="TEXT"><value><option name="BACKGROUND" value="${bg}"/></value></option></attributes></scheme>`,
|
||||
).scheme.parent,
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
it("ignores CSS injection on import and from edited settings", () => {
|
||||
const { scheme, applied, skipped } = parseIcls(
|
||||
'<scheme name="evil"><colors><option name="CARET_COLOR" value="fff;--bad:red"/></colors><attributes><option name="TEXT"><value><option name="BACKGROUND" value="fff}body{display:none"/></value></option></attributes></scheme>',
|
||||
);
|
||||
expect([applied, skipped]).toEqual([0, 2]);
|
||||
expect(scheme.attributes).toEqual({});
|
||||
const customs = {
|
||||
"custom-test": {
|
||||
name: "Test",
|
||||
parent: "dark",
|
||||
attributes: {
|
||||
keyword: { foreground: "red", background: "url(https://evil)", effectColor: "var(--bad)", effect: "wave" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const valid = customSchemes(customs);
|
||||
expect(valid["custom-test"]?.attributes.keyword).toEqual({ effect: "wave" });
|
||||
const css = schemeCss("custom-test", undefined, null, valid);
|
||||
expect(css).not.toMatch(/evil|url\(|--bad/);
|
||||
expect(schemeCss("dark", { dark: { keyword: { foreground: "url(https://evil)" } } }, null)).not.toContain("evil");
|
||||
});
|
||||
|
||||
it("keeps both, overwrites a custom, and never overwrites a built-in", () => {
|
||||
const parsed = parseIcls(fixture("forest")).scheme;
|
||||
const first = importScheme({}, parsed);
|
||||
const both = importScheme(first.customs, parsed);
|
||||
expect(both.customs[both.id]?.name).toBe("Forest & Moss (2)");
|
||||
const third = importScheme(both.customs, parsed);
|
||||
expect(third.customs[third.id]?.name).toBe("Forest & Moss (3)");
|
||||
const overwrite = importScheme(both.customs, { ...parsed, parent: "light" }, true);
|
||||
expect(overwrite.id).toBe(first.id);
|
||||
expect(overwrite.customs[first.id]?.parent).toBe("light");
|
||||
const builtin = importScheme({}, { ...parsed, name: SCHEMES[0]!.name }, true);
|
||||
expect(builtin.customs[builtin.id]?.name).toBe(`${SCHEMES[0]!.name} (2)`);
|
||||
expect(SCHEMES.some((s) => s.id === builtin.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses imported values under either theme, overrides them, and falls back after deletion", () => {
|
||||
const { id, customs } = importScheme({}, parseIcls(fixture("forest")).scheme);
|
||||
const overrides = { [id]: { keyword: { foreground: "#abcdef" } } };
|
||||
for (const theme of ["light", "dark"]) {
|
||||
const root = document.createElement("div");
|
||||
root.dataset.theme = theme;
|
||||
const css = schemeCss(id, overrides, root, customs);
|
||||
expect(css).toContain("--bg-editor: #18281e !important");
|
||||
expect(css.lastIndexOf("--syn-keyword: #abcdef")).toBeGreaterThan(css.indexOf("--syn-keyword: #99dd77"));
|
||||
expect(effectiveScheme("theme-default", root, customs)).toBe(theme);
|
||||
const deleted = deleteScheme(customs, overrides, id, id);
|
||||
expect(deleted).toEqual({ customs: {}, overrides: {}, selected: "theme-default" });
|
||||
expect(effectiveScheme(id, root, deleted.customs)).toBe(theme);
|
||||
expect(schemeCss(deleted.selected, deleted.overrides, root, deleted.customs)).toBe("");
|
||||
expect(deleteScheme(customs, overrides, "light", id).selected).toBe("light");
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["forest", "daylight"])("exports visible values and re-imports %s with overrides", (name) => {
|
||||
const { id, customs } = importScheme({}, parseIcls(fixture(name)).scheme);
|
||||
const override = {
|
||||
keyword: { foreground: "#123456", bold: false, italic: true },
|
||||
error: { effectColor: "#aa1122" },
|
||||
};
|
||||
const xml = exportIcls(id, customs, override);
|
||||
const imported = parseIcls(xml).scheme;
|
||||
expect(imported.name).toBe(customs[id]!.name);
|
||||
expect(imported.parent).toBe(customs[id]!.parent);
|
||||
expect(imported.attributes.keyword).toEqual(override.keyword);
|
||||
expect(imported.attributes.text).toEqual(customs[id]!.attributes.text);
|
||||
expect(imported.attributes.error?.effectColor).toBe("#aa1122");
|
||||
});
|
||||
|
||||
it("exports shorthand colors as full RGB", () => {
|
||||
const { id, customs } = importScheme({}, parseIcls(fixture("forest")).scheme);
|
||||
expect(
|
||||
parseIcls(exportIcls(id, customs, { keyword: { foreground: "#abc" } })).scheme.attributes.keyword?.foreground,
|
||||
).toBe("#aabbcc");
|
||||
});
|
||||
|
||||
it("round trips custom settings and lists them without changing built-ins", () => {
|
||||
const { id, customs } = importScheme({}, parseIcls(fixture("forest")).scheme);
|
||||
const settings = validateSettings(
|
||||
JSON.parse(
|
||||
JSON.stringify({
|
||||
editor: {
|
||||
customSchemes: customs,
|
||||
colorScheme: id,
|
||||
colorOverrides: { [id]: { string: { foreground: "#123456" } } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(settings.editor.customSchemes).toEqual(customs);
|
||||
expect(settings.editor.colorScheme).toBe(id);
|
||||
expect(listSchemes(customs).at(-1)?.name).toBe("Forest & Moss");
|
||||
expect(listSchemes(customs)).toHaveLength(SCHEMES.length + 1);
|
||||
expect(isClientSetting("editor.customSchemes")).toBe(true);
|
||||
const previous = useProject.getState().settings;
|
||||
try {
|
||||
useProject.setState({ settings: settings as Settings });
|
||||
const items = colorSchemeItems(id);
|
||||
expect(items.find((s) => "id" in s && s.id === `scheme.${id}`)).toMatchObject({
|
||||
label: "Forest & Moss",
|
||||
checked: true,
|
||||
});
|
||||
expect(items.find((s) => "id" in s && s.id === "scheme.theme-default")).toMatchObject({ checked: false });
|
||||
} finally {
|
||||
useProject.setState({ settings: previous });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
/** IntelliJ editor schemes. Unsupported keys are counted, never copied into CSS. */
|
||||
import { ATTRIBUTES, customSchemes, isHexColor, listSchemes, resolvedSchemeTokens } from "./colorScheme";
|
||||
import { isBuiltinTheme, themeKind } from "./themes";
|
||||
import type { CustomColorScheme, CustomColorSchemes, TextAttributesOverride } from "./editorSettings";
|
||||
|
||||
// Many language keys share the editor's language-default tokens.
|
||||
const ATTRIBUTE_NAMES: Record<string, string> = {
|
||||
TEXT: "text",
|
||||
DEFAULT_TEXT: "text",
|
||||
DEFAULT_KEYWORD: "keyword",
|
||||
DEFAULT_IDENTIFIER: "identifier",
|
||||
DEFAULT_LOCAL_VARIABLE: "identifier",
|
||||
DEFAULT_PARAMETER: "parameter",
|
||||
DEFAULT_CONSTANT: "constant",
|
||||
DEFAULT_INSTANCE_FIELD: "field",
|
||||
DEFAULT_STATIC_FIELD: "constant",
|
||||
DEFAULT_FUNCTION_DECLARATION: "functionDeclaration",
|
||||
DEFAULT_FUNCTION_CALL: "functionCall",
|
||||
DEFAULT_PREDEFINED_SYMBOL: "predefined",
|
||||
DEFAULT_CLASS_NAME: "className",
|
||||
DEFAULT_INTERFACE_NAME: "className",
|
||||
DEFAULT_NUMBER: "number",
|
||||
DEFAULT_STRING: "string",
|
||||
DEFAULT_VALID_STRING_ESCAPE: "escape",
|
||||
DEFAULT_INVALID_STRING_ESCAPE: "invalid",
|
||||
DEFAULT_LINE_COMMENT: "lineComment",
|
||||
DEFAULT_BLOCK_COMMENT: "lineComment",
|
||||
DEFAULT_DOC_COMMENT: "docComment",
|
||||
DEFAULT_METADATA: "metadata",
|
||||
DEFAULT_OPERATION_SIGN: "operator",
|
||||
DEFAULT_BRACES: "operator",
|
||||
DEFAULT_BRACKETS: "operator",
|
||||
DEFAULT_PARENTHS: "operator",
|
||||
DEFAULT_DOT: "operator",
|
||||
DEFAULT_COMMA: "operator",
|
||||
DEFAULT_SEMICOLON: "operator",
|
||||
DEFAULT_MARKUP_TAG: "markupTag",
|
||||
DEFAULT_MARKUP_ATTRIBUTE: "markupAttribute",
|
||||
BAD_CHARACTER: "invalid",
|
||||
TODO_DEFAULT_ATTRIBUTES: "todo",
|
||||
SEARCH_RESULT_ATTRIBUTES: "searchResult",
|
||||
TEXT_SEARCH_RESULT_ATTRIBUTES: "searchResultCurrent",
|
||||
WRITE_SEARCH_RESULT_ATTRIBUTES: "identifierUnderCaretWrite",
|
||||
IDENTIFIER_UNDER_CARET_ATTRIBUTES: "identifierUnderCaret",
|
||||
MATCHED_BRACE_ATTRIBUTES: "matchedBrace",
|
||||
ERRORS_ATTRIBUTES: "error",
|
||||
WARNING_ATTRIBUTES: "warning",
|
||||
WEAK_WARNING_ATTRIBUTES: "weakWarning",
|
||||
NOT_USED_ELEMENT_ATTRIBUTES: "unused",
|
||||
FOLDED_TEXT_ATTRIBUTES: "folded",
|
||||
INLAY_DEFAULT: "inlay",
|
||||
PY_SELF_PARAMETER: "pySelf",
|
||||
PY_KEYWORD_ARGUMENT: "pyKeywordArg",
|
||||
PY_DOC_STRING: "pyDocstring",
|
||||
PY_BUILTIN_NAME: "pyBuiltin",
|
||||
PY_DECORATOR: "pyDecorator",
|
||||
MARKDOWN_HEADER_LEVEL_1: "mdHeading",
|
||||
MARKDOWN_CODE_SPAN: "mdCode",
|
||||
MARKDOWN_LINK_TEXT: "mdLink",
|
||||
REGEXP: "regexp",
|
||||
};
|
||||
const LANGUAGE_KEYS: Record<string, string> = {
|
||||
KEYWORD: "keyword",
|
||||
NUMBER: "number",
|
||||
STRING: "string",
|
||||
STRING_TEXT: "string",
|
||||
VALID_STRING_ESCAPE: "escape",
|
||||
LINE_COMMENT: "lineComment",
|
||||
BLOCK_COMMENT: "lineComment",
|
||||
DOC_COMMENT: "docComment",
|
||||
CLASS: "className",
|
||||
CLASS_NAME: "className",
|
||||
INTERFACE: "className",
|
||||
ENUM: "className",
|
||||
TYPE_PARAMETER: "className",
|
||||
ANNOTATION_NAME: "metadata",
|
||||
ANNOTATION: "metadata",
|
||||
LOCAL_VARIABLE: "identifier",
|
||||
PARAMETER: "parameter",
|
||||
INSTANCE_FIELD: "field",
|
||||
STATIC_FIELD: "constant",
|
||||
STATIC_FINAL_FIELD: "constant",
|
||||
PROPERTY: "field",
|
||||
BACKING_FIELD_VARIABLE: "field",
|
||||
FUNCTION_DECLARATION: "functionDeclaration",
|
||||
METHOD_DECLARATION: "functionDeclaration",
|
||||
FUNCTION_CALL: "functionCall",
|
||||
METHOD_CALL: "functionCall",
|
||||
STATIC_METHOD: "functionCall",
|
||||
CONSTRUCTOR_CALL: "className",
|
||||
OPERATION_SIGN: "operator",
|
||||
BRACES: "operator",
|
||||
BRACKETS: "operator",
|
||||
PARENTH: "operator",
|
||||
PARENTHS: "operator",
|
||||
};
|
||||
for (const [name, id] of Object.entries(LANGUAGE_KEYS)) {
|
||||
ATTRIBUTE_NAMES[`JAVA_${name}`] = id;
|
||||
ATTRIBUTE_NAMES[`KOTLIN_${name}`] = id;
|
||||
ATTRIBUTE_NAMES[name] = id;
|
||||
}
|
||||
Object.assign(ATTRIBUTE_NAMES, {
|
||||
KOTLIN_MUTABLE_VARIABLE: "identifier",
|
||||
KOTLIN_PACKAGE_PROPERTY: "field",
|
||||
KOTLIN_SYNTHETIC_EXTENSION_PROPERTY: "field",
|
||||
KOTLIN_EXTENSION_FUNCTION_CALL: "functionCall",
|
||||
KOTLIN_NAMED_ARGUMENT: "parameter",
|
||||
KOTLIN_OBJECT: "className",
|
||||
});
|
||||
|
||||
const COLOR_NAMES: Record<string, [string, "foreground" | "background"]> = {
|
||||
CARET_COLOR: ["caret", "foreground"],
|
||||
CARET_ROW_COLOR: ["caretRow", "background"],
|
||||
SELECTION_BACKGROUND: ["selection", "background"],
|
||||
INACTIVE_SELECTION_BACKGROUND: ["selectionInactive", "background"],
|
||||
GUTTER_BACKGROUND: ["gutter", "background"],
|
||||
LINE_NUMBERS_COLOR: ["lineNumber", "foreground"],
|
||||
LINE_NUMBER_ON_CARET_ROW_COLOR: ["lineNumberCaret", "foreground"],
|
||||
WHITESPACES: ["whitespaces", "foreground"],
|
||||
INDENT_GUIDE: ["indentGuide", "foreground"],
|
||||
RIGHT_MARGIN_COLOR: ["rightMargin", "foreground"],
|
||||
METHOD_SEPARATORS_COLOR: ["methodSeparator", "foreground"],
|
||||
};
|
||||
// Preserve editor attributes without a standard exchange name on our own round trip.
|
||||
for (const attr of ATTRIBUTES)
|
||||
if (!Object.values(ATTRIBUTE_NAMES).includes(attr.id)) ATTRIBUTE_NAMES[`OPENIDEA_${attr.id}`] = attr.id;
|
||||
|
||||
const EFFECTS: Record<string, TextAttributesOverride["effect"]> = {
|
||||
"0": "underline",
|
||||
"1": "wave",
|
||||
"3": "strikeout",
|
||||
"4": "underline",
|
||||
};
|
||||
const children = (el: Element, tag: string) => Array.from(el.children).filter((e) => e.tagName === tag);
|
||||
const color = (v: string | null): string | undefined =>
|
||||
v && /^[0-9a-f]{1,6}$/i.test(v) ? `#${v.padStart(6, "0").toLowerCase()}` : undefined;
|
||||
|
||||
export function parseIcls(xml: string): { scheme: CustomColorScheme; applied: number; skipped: number } {
|
||||
const doc = new DOMParser().parseFromString(xml, "application/xml");
|
||||
const root = doc.documentElement;
|
||||
if (
|
||||
doc.querySelector("parsererror") ||
|
||||
doc.doctype ||
|
||||
root.tagName !== "scheme" ||
|
||||
!root.getAttribute("name")?.trim()
|
||||
)
|
||||
throw new Error("Invalid .icls color scheme");
|
||||
const attributes: Record<string, TextAttributesOverride> = {};
|
||||
let applied = 0;
|
||||
let skipped = 0;
|
||||
const merge = (id: string, value: TextAttributesOverride) => {
|
||||
attributes[id] = { ...attributes[id], ...value };
|
||||
};
|
||||
for (const section of Array.from(root.children)) {
|
||||
if (section.tagName === "colors")
|
||||
for (const entry of children(section, "option")) {
|
||||
const name = entry.getAttribute("name") ?? "";
|
||||
const target = Object.hasOwn(COLOR_NAMES, name) ? COLOR_NAMES[name] : undefined;
|
||||
const c = color(entry.getAttribute("value"));
|
||||
if (target && c) {
|
||||
merge(target[0], { [target[1]]: c });
|
||||
applied++;
|
||||
} else skipped++;
|
||||
}
|
||||
if (section.tagName === "attributes")
|
||||
for (const entry of children(section, "option")) {
|
||||
const name = entry.getAttribute("name") ?? "";
|
||||
const id = Object.hasOwn(ATTRIBUTE_NAMES, name) ? ATTRIBUTE_NAMES[name] : undefined;
|
||||
if (!id) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const value = children(entry, "value")[0];
|
||||
if (!value) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
for (const option of children(value, "option")) {
|
||||
const name = option.getAttribute("name");
|
||||
const raw = option.getAttribute("value");
|
||||
const c = color(raw);
|
||||
const field =
|
||||
name === "FOREGROUND"
|
||||
? "foreground"
|
||||
: name === "BACKGROUND"
|
||||
? "background"
|
||||
: name === "EFFECT_COLOR"
|
||||
? "effectColor"
|
||||
: undefined;
|
||||
const attr = ATTRIBUTES.find((a) => a.id === id)!;
|
||||
const supported =
|
||||
field === "foreground"
|
||||
? !!attr.fg
|
||||
: field === "background"
|
||||
? !!attr.bg || !!attr.token
|
||||
: !!attr.effect || !!attr.token;
|
||||
if (field && c && supported) {
|
||||
merge(id, { [field]: c });
|
||||
applied++;
|
||||
} else if (name === "FONT_TYPE" && attr.token && raw && /^[0-3]$/.test(raw)) {
|
||||
merge(id, { bold: !!(Number(raw) & 1), italic: !!(Number(raw) & 2) });
|
||||
applied++;
|
||||
} else if (name === "EFFECT_TYPE" && (attr.token || attr.effect) && raw && Object.hasOwn(EFFECTS, raw)) {
|
||||
merge(id, { effect: EFFECTS[raw] });
|
||||
applied++;
|
||||
} else skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
const parentName = root.getAttribute("parent_scheme") ?? "";
|
||||
const bg = attributes.text?.background;
|
||||
const rgb = bg ? [1, 3, 5].map((i) => parseInt(bg.slice(i, i + 2), 16)) : [255, 255, 255];
|
||||
const dark = rgb[0]! * 0.299 + rgb[1]! * 0.587 + rgb[2]! * 0.114 < 128;
|
||||
const parent = /darcula|dark/i.test(parentName)
|
||||
? "dark"
|
||||
: /default|light/i.test(parentName)
|
||||
? "light"
|
||||
: dark
|
||||
? "dark"
|
||||
: "light";
|
||||
return { scheme: { name: root.getAttribute("name")!.trim(), parent, attributes }, applied, skipped };
|
||||
}
|
||||
|
||||
/** Name collisions include built-ins; imports never replace a built-in. */
|
||||
export function importScheme(customs: CustomColorSchemes, scheme: CustomColorScheme, overwrite = false) {
|
||||
const existing = Object.entries(customs).find(([, s]) => s.name === scheme.name);
|
||||
const names = new Set(listSchemes(customs).map((s) => s.name));
|
||||
let name = scheme.name;
|
||||
if (!(overwrite && existing)) for (let i = 2; names.has(name); i++) name = `${scheme.name} (${i})`;
|
||||
const id = overwrite && existing ? existing[0] : `custom-${crypto.randomUUID()}`;
|
||||
return { id, customs: { ...customs, [id]: { ...scheme, name } } };
|
||||
}
|
||||
|
||||
export function deleteScheme(
|
||||
customs: CustomColorSchemes,
|
||||
overrides: Record<string, Record<string, TextAttributesOverride>>,
|
||||
selected: string,
|
||||
id: string,
|
||||
) {
|
||||
return {
|
||||
customs: Object.fromEntries(Object.entries(customs).filter(([key]) => key !== id)),
|
||||
overrides: Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== id)),
|
||||
selected: selected === id ? "theme-default" : selected,
|
||||
};
|
||||
}
|
||||
|
||||
const exportColor = (c: string) =>
|
||||
c.length === 4
|
||||
? c
|
||||
.slice(1)
|
||||
.split("")
|
||||
.map((ch) => ch + ch)
|
||||
.join("")
|
||||
: c.slice(1);
|
||||
const escapeXml = (v: string) =>
|
||||
v.replace(/&/g, "&").replace(/</g, "<").replace(/"/g, """).replace(/>/g, ">");
|
||||
export function exportIcls(
|
||||
id: string,
|
||||
customs: CustomColorSchemes,
|
||||
overrides: Record<string, TextAttributesOverride> = {},
|
||||
): string {
|
||||
const custom = customSchemes(customs)[id];
|
||||
const name = listSchemes(customs).find((s) => s.id === id)?.name ?? id;
|
||||
const tokens = resolvedSchemeTokens(id, customs);
|
||||
const values = (attr: (typeof ATTRIBUTES)[number]): TextAttributesOverride => ({
|
||||
...(attr.token
|
||||
? {
|
||||
bold: /bold|[6-9]00/.test(tokens.get(`--syn-${attr.token}-bold`) ?? ""),
|
||||
italic:
|
||||
(tokens.get(`--syn-${attr.token}-italic`) ??
|
||||
(attr.token === "comment"
|
||||
? tokens.get("--syn-comment-style")
|
||||
: (attr.italicOnly && attr.defaultItalic !== false) || ["doc-comment", "docstring"].includes(attr.token)
|
||||
? "italic"
|
||||
: "normal")) === "italic",
|
||||
}
|
||||
: {}),
|
||||
...(attr.fg && isHexColor(tokens.get(attr.fg)) ? { foreground: tokens.get(attr.fg) } : {}),
|
||||
...(attr.bg && isHexColor(tokens.get(attr.bg)) ? { background: tokens.get(attr.bg) } : {}),
|
||||
...(attr.effect && isHexColor(tokens.get(attr.effect)) ? { effectColor: tokens.get(attr.effect) } : {}),
|
||||
...custom?.attributes[attr.id],
|
||||
...overrides[attr.id],
|
||||
});
|
||||
const colors: string[] = [];
|
||||
const attributes: string[] = [];
|
||||
for (const [key, [id, field]] of Object.entries(COLOR_NAMES)) {
|
||||
const attr = ATTRIBUTES.find((a) => a.id === id);
|
||||
const c = attr && values(attr)[field];
|
||||
if (isHexColor(c)) colors.push(` <option name="${key}" value="${exportColor(c)}" />`);
|
||||
}
|
||||
for (const attr of ATTRIBUTES) {
|
||||
const key = Object.keys(ATTRIBUTE_NAMES).find((key) => ATTRIBUTE_NAMES[key] === attr.id);
|
||||
if (!key) continue;
|
||||
const v = values(attr);
|
||||
const opts: string[] = [];
|
||||
for (const [key, field] of [
|
||||
["FOREGROUND", "foreground"],
|
||||
["BACKGROUND", "background"],
|
||||
["EFFECT_COLOR", "effectColor"],
|
||||
] as const)
|
||||
if (isHexColor(v[field])) opts.push(`<option name="${key}" value="${exportColor(v[field]!)}" />`);
|
||||
if (attr.token) opts.push(`<option name="FONT_TYPE" value="${(v.bold ? 1 : 0) + (v.italic ? 2 : 0)}" />`);
|
||||
const effect = Object.entries(EFFECTS).find(([, value]) => value === v.effect)?.[0];
|
||||
if (effect) opts.push(`<option name="EFFECT_TYPE" value="${effect}" />`);
|
||||
if (opts.length) attributes.push(` <option name="${key}"><value>${opts.join("")}</value></option>`);
|
||||
}
|
||||
const parent = custom?.parent ?? (isBuiltinTheme(id) ? themeKind(id) : "dark");
|
||||
return `<scheme name="${escapeXml(name)}" version="142" parent_scheme="${parent === "dark" ? "Darcula" : "Default"}">\n <colors>\n${colors.join("\n")}\n </colors>\n <attributes>\n${attributes.join("\n")}\n </attributes>\n</scheme>\n`;
|
||||
}
|
||||
@@ -5228,5 +5228,16 @@
|
||||
"For clipboard images: {timestamp} becomes YYYYMMDD-HHmmss. Source file names are preserved.": "剪贴板图片中的 {timestamp} 替换为 YYYYMMDD-HHmmss。保留源文件名称。",
|
||||
"Image folder must be relative.": "图片文件夹必须使用相对路径。",
|
||||
"Image exceeds 16 MB.": "图片超过 16 MB。",
|
||||
"Cannot insert image": "无法插入图片"
|
||||
"Cannot insert image": "无法插入图片",
|
||||
"Import Scheme": "导入配色方案",
|
||||
"Export Scheme": "导出配色方案",
|
||||
"Delete Scheme": "删除配色方案",
|
||||
"Keep Both": "保留两个",
|
||||
"Scheme exported.": "配色方案已导出。",
|
||||
"Invalid .icls color scheme": "无效的 .icls 配色方案",
|
||||
"IntelliJ Color Scheme": "IntelliJ 配色方案",
|
||||
"A scheme named '{name}' already exists.": "名为“{name}”的配色方案已存在。",
|
||||
"Applied {applied} values; skipped {skipped} unsupported or invalid values.": "已应用 {applied} 个值;跳过了 {skipped} 个不支持或无效的值。",
|
||||
"Delete color scheme '{name}'?": "删除配色方案“{name}”?",
|
||||
"Overwrite": "覆盖"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача