fix(settings): use IntelliJ's codes for .icls effect types (#2203)

The `.icls` effect table used the wrong `EFFECT_TYPE` codes, so imports lost underlines and wavy underlines, and exports gave IntelliJ a box where the IDE showed an underline. Codes now match IntelliJ: 1 underline, 2 wave, 3 strikeout, 4 and 5 become underline, 0 and 6 are skipped. As in IntelliJ, an effect without an effect colour is not drawn on import, and export writes a colour for every effect, leaving a cleared error or warning effect off. Export also keeps effects defined by theme tokens, such as the doc tag underline in Islands Light.

Co-authored-by: lleviathe <[email protected]>
Цей коміт міститься в:
lleviathe authored and GitHub committed 2026-11-09 07:01:21 +08:00
1 джерело 02ff61e6a7
коміт 9a76d1766d
3 files changed
+133 -8

No files matched your search

+1 -1
Переглянути файл
@@ -15,7 +15,7 @@
<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="4"/></value></option>
<option name="ERRORS_ATTRIBUTES"><value><option name="EFFECT_COLOR" value="ff6655"/><option name="EFFECT_TYPE" value="2"/></value></option>
<option name="UNKNOWN_LANGUAGE_TOKEN"><value><option name="FOREGROUND" value="123456"/></value></option>
</attributes>
</scheme>
+92 -1
Переглянути файл
@@ -1,6 +1,14 @@
import { readFileSync } from "node:fs";
import { beforeAll, describe, expect, it } from "vitest";
import { _setSchemeSource, customSchemes, effectiveScheme, listSchemes, SCHEMES, schemeCss } from "./colorScheme";
import {
_setSchemeSource,
CLEAR_COLOR,
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";
@@ -114,6 +122,89 @@ describe(".icls schemes", () => {
expect(customs).toEqual(before);
});
it.each([
["0", undefined],
["1", "underline"],
["2", "wave"],
["3", "strikeout"],
["4", "underline"],
["5", "underline"],
["6", undefined],
] as const)("imports IntelliJ effect type %s as %s", (code, effect) => {
const { scheme } = parseIcls(`<scheme name="Effects"><attributes>
<option name="DEFAULT_KEYWORD"><value><option name="EFFECT_COLOR" value="aa1122"/><option name="EFFECT_TYPE" value="${code}"/></value></option>
</attributes></scheme>`);
expect(scheme.attributes.keyword?.effect).toBe(effect);
});
it("imports no effect without an effect colour, which IntelliJ does not draw", () => {
const { scheme } = parseIcls(`<scheme name="Effects"><attributes>
<option name="DEFAULT_KEYWORD"><value><option name="FOREGROUND" value="112233"/><option name="EFFECT_TYPE" value="3"/></value></option>
</attributes></scheme>`);
expect(scheme.attributes.keyword).toEqual({ foreground: "#112233", bold: false, italic: false });
});
it("exports effects with IntelliJ's codes and a colour IntelliJ draws them in", () => {
const { id, customs } = importScheme({}, parseIcls(fixture("forest")).scheme);
const xml = exportIcls(id, customs, {
keyword: { effect: "underline" },
number: { effect: "strikeout", effectColor: "#aa1122" },
});
const effect = (name: string) => {
const value = new DOMParser().parseFromString(xml, "application/xml").querySelector(`option[name="${name}"]`);
const option = (key: string) => value?.querySelector(`option[name="${key}"]`)?.getAttribute("value");
return [option("EFFECT_TYPE"), option("EFFECT_COLOR")];
};
expect(effect("DEFAULT_KEYWORD")).toEqual(["1", "aadd88"]);
expect(effect("ERRORS_ATTRIBUTES")).toEqual(["2", "ff6655"]);
expect(effect("DEFAULT_NUMBER")).toEqual(["3", "aa1122"]);
const attrs = parseIcls(xml).scheme.attributes;
expect(attrs.keyword).toMatchObject({ effect: "underline", effectColor: "#aadd88" });
expect(attrs.error).toEqual({ effect: "wave", effectColor: "#ff6655" });
});
it("exports no effect whose colour was cleared", () => {
const { id, customs } = importScheme({}, parseIcls(fixture("forest")).scheme);
const xml = exportIcls(id, customs, { error: { effectColor: CLEAR_COLOR } });
expect(parseIcls(xml).scheme.attributes.error).toBeUndefined();
});
it("round trips the Islands Light doc comment tag underline", () => {
const xml = exportIcls("light", {});
const doc = new DOMParser().parseFromString(xml, "application/xml");
const tag = doc.querySelector('option[name="DEFAULT_DOC_COMMENT_TAG"]');
expect(tag?.querySelector('option[name="EFFECT_TYPE"]')?.getAttribute("value")).toBe("1");
expect(tag?.querySelector('option[name="EFFECT_COLOR"]')?.getAttribute("value")).toBe("999999");
const scheme = parseIcls(xml).scheme;
expect(scheme.attributes.docCommentTag).toMatchObject({ effect: "underline", effectColor: "#999999" });
const imported = parseIcls(exportIcls("custom-docs", { "custom-docs": scheme })).scheme;
expect(imported.attributes.docCommentTag).toEqual(scheme.attributes.docCommentTag);
});
it("round trips language effects and exports none without changing shared state", () => {
const scheme = parseIcls(`<scheme name="Effects"><attributes>
<option name="JAVA_DOC_TAG"><value><option name="FOREGROUND" value="112233"/><option name="EFFECT_COLOR" value="abcdef"/><option name="EFFECT_TYPE" value="2"/></value></option>
</attributes></scheme>`).scheme;
const customs = { "custom-effects": scheme };
const overrides = {
keyword: { effect: "underline" as const },
number: { effect: "none" as const, effectColor: "#ff0000" },
"java.string": { effect: "strikeout" as const },
};
const before = structuredClone({ customs, overrides });
const xml = exportIcls("custom-effects", customs, overrides);
expect({ customs, overrides }).toEqual(before);
const doc = new DOMParser().parseFromString(xml, "application/xml");
expect(doc.querySelector('option[name="DEFAULT_NUMBER"] option[name="EFFECT_TYPE"]')).toBeNull();
const imported = parseIcls(xml).scheme;
expect(imported.attributes["java.docCommentTag"]).toMatchObject({ effect: "wave", effectColor: "#abcdef" });
expect(imported.attributes["java.string"]).toMatchObject({ effect: "strikeout" });
expect(imported.attributes.number?.effect).toBeUndefined();
const roundTrip = parseIcls(exportIcls("custom-effects", { "custom-effects": imported })).scheme;
expect(roundTrip.attributes["java.docCommentTag"]).toEqual(imported.attributes["java.docCommentTag"]);
expect(roundTrip.attributes["java.string"]).toEqual(imported.attributes["java.string"]);
});
it("imports PyCharm dot keys after shared defaults without inherited font effects", () => {
const xml = `<scheme name="Python"><attributes>
<option name="PY.KEYWORD"><value><option name="FOREGROUND" value="112233"/></value></option>
+40 -6
Переглянути файл
@@ -1,5 +1,13 @@
/** IntelliJ editor schemes. Unsupported keys are counted, never copied into CSS. */
import { ATTRIBUTES, customSchemes, isHexColor, listSchemes, resolvedSchemeTokens } from "./colorScheme";
import {
ATTRIBUTES,
CLEAR_COLOR,
customSchemes,
isHexColor,
listSchemes,
resolvedAttribute,
resolvedSchemeTokens,
} from "./colorScheme";
import { isBuiltinTheme, themeKind } from "./themes";
import type { CustomColorScheme, CustomColorSchemes, TextAttributesOverride } from "./editorSettings";
@@ -182,12 +190,19 @@ const COLOR_NAMES: Record<string, [string, "foreground" | "background"]> = {
for (const attr of ATTRIBUTES)
if (!Object.values(ATTRIBUTE_NAMES).includes(attr.id)) ATTRIBUTE_NAMES[`OPENIDEA_${attr.id}`] = attr.id;
// IntelliJ's EFFECT_TYPE codes. The box (0) and faded text (6) have no counterpart here; the bold and the
// bold dotted line (4, 5) become an underline.
const EFFECTS: Record<string, TextAttributesOverride["effect"]> = {
"0": "underline",
"1": "underline",
"2": "wave",
"3": "strikeout",
"4": "wave",
"4": "underline",
"5": "underline",
"6": "underline",
};
const EFFECT_CODES: Partial<Record<NonNullable<TextAttributesOverride["effect"]>, string>> = {
underline: "1",
wave: "2",
strikeout: "3",
};
const children = (el: Element, tag: string) => Array.from(el.children).filter((e) => e.tagName === tag);
const color = (v: string | null): string | undefined =>
@@ -263,6 +278,10 @@ export function parseIcls(xml: string): { scheme: CustomColorScheme; applied: nu
// An explicit TextAttributes value defaults to Font.PLAIN, not the last alias's style.
if (attr.token) merge(id, { bold: false, italic: false });
if (name.startsWith("PY.")) merge(id, { effect: "none" });
// IntelliJ draws no effect without an effect colour.
const drawn = children(value, "option").some(
(o) => o.getAttribute("name") === "EFFECT_COLOR" && color(o.getAttribute("value")),
);
for (const option of children(value, "option")) {
const name = option.getAttribute("name");
const raw = option.getAttribute("value");
@@ -287,7 +306,13 @@ export function parseIcls(xml: string): { scheme: CustomColorScheme; applied: nu
} 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)) {
} else if (
name === "EFFECT_TYPE" &&
drawn &&
(attr.token || attr.effect) &&
raw &&
Object.hasOwn(EFFECTS, raw)
) {
merge(id, { effect: EFFECTS[raw] });
applied++;
} else skipped++;
@@ -353,6 +378,11 @@ export function exportIcls(
const custom = customSchemes(customs)[id];
const name = listSchemes(customs).find((s) => s.id === id)?.name ?? id;
const tokens = resolvedSchemeTokens(id, customs);
const tokenEffect = (attr: (typeof ATTRIBUTES)[number]): TextAttributesOverride => {
if (!attr.token) return {};
const { effect, effectColor } = resolvedAttribute(id, attr.id, customs);
return { effect, effectColor };
};
const values = (attr: (typeof ATTRIBUTES)[number]): TextAttributesOverride => ({
...(attr.token
? {
@@ -369,6 +399,7 @@ export function exportIcls(
...(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) } : {}),
...tokenEffect(attr),
...custom?.attributes[attr.id],
...overrides[attr.id],
});
@@ -379,11 +410,15 @@ export function exportIcls(
const c = attr && values(attr)[field];
if (isHexColor(c)) colors.push(` <option name="${key}" value="${exportColor(c)}" />`);
}
const text = values(ATTRIBUTES.find((a) => a.id === "text")!);
for (const attr of ATTRIBUTES) {
if (attr.language && !custom?.attributes[attr.id] && !overrides[attr.id]) continue;
const key = Object.keys(ATTRIBUTE_NAMES).find((key) => ATTRIBUTE_NAMES[key] === attr.id);
if (!key) continue;
const v = values(attr);
// A cleared effect colour hides the effect. One left unset takes the text's here, and IntelliJ would draw none.
const effect = v.effect && v.effectColor !== CLEAR_COLOR ? EFFECT_CODES[v.effect] : undefined;
if (effect && !isHexColor(v.effectColor)) v.effectColor = v.foreground ?? text.foreground;
const opts: string[] = [];
for (const [key, field] of [
["FOREGROUND", "foreground"],
@@ -392,7 +427,6 @@ export function exportIcls(
] 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>`);
}