diff --git a/src/platform/__fixtures__/colorSchemes/forest.icls b/src/platform/__fixtures__/colorSchemes/forest.icls
index b1a61ccff..56801fcb5 100644
--- a/src/platform/__fixtures__/colorSchemes/forest.icls
+++ b/src/platform/__fixtures__/colorSchemes/forest.icls
@@ -15,7 +15,7 @@
-
+
diff --git a/src/platform/icls.test.ts b/src/platform/icls.test.ts
index 7d4f9ca59..f5d805e5e 100644
--- a/src/platform/icls.test.ts
+++ b/src/platform/icls.test.ts
@@ -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(`
+
+ `);
+ expect(scheme.attributes.keyword?.effect).toBe(effect);
+ });
+
+ it("imports no effect without an effect colour, which IntelliJ does not draw", () => {
+ const { scheme } = parseIcls(`
+
+ `);
+ 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;
+ 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 = `
diff --git a/src/platform/icls.ts b/src/platform/icls.ts
index 0e5abb23f..ed058df10 100644
--- a/src/platform/icls.ts
+++ b/src/platform/icls.ts
@@ -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 = {
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 = {
- "0": "underline",
+ "1": "underline",
+ "2": "wave",
"3": "strikeout",
- "4": "wave",
+ "4": "underline",
"5": "underline",
- "6": "underline",
+};
+const EFFECT_CODES: Partial, 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(` `);
}
+ 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(``);
if (attr.token) opts.push(``);
- const effect = Object.entries(EFFECTS).find(([, value]) => value === v.effect)?.[0];
if (effect) opts.push(``);
if (opts.length) attributes.push(` ${opts.join("")}`);
}