From 9a76d1766de4895cdaf42373a075c777432b891c Mon Sep 17 00:00:00 2001 From: lleviathe <95321986+lleviathe@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:01:21 +0400 Subject: [PATCH] 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 <95321986+lleviathe@users.noreply.github.com> --- .../__fixtures__/colorSchemes/forest.icls | 2 +- src/platform/icls.test.ts | 93 ++++++++++++++++++- src/platform/icls.ts | 46 +++++++-- 3 files changed, 133 insertions(+), 8 deletions(-) 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(` `); }