From bde19959b939acfd325d32816d19f57efbcec3db Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:41:39 +0800 Subject: [PATCH] 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 --- crates/ide-core/src/settings.rs | 12 + crates/ide-workspace/src/settings.rs | 60 +++- src/bindings.ts | 21 ++ src/editor/core/colorSchemeRuntime.ts | 2 +- .../settings/__tests__/colorScheme.test.tsx | 116 +++++++ src/features/settings/pages/behavior.tsx | 5 +- .../settings/pages/editor/Editor.module.css | 1 + .../settings/pages/editor/colorScheme.tsx | 185 +++++++++-- .../__fixtures__/colorSchemes/daylight.icls | 8 + .../__fixtures__/colorSchemes/forest.icls | 21 ++ src/platform/clientSettings.ts | 1 + src/platform/colorScheme.ts | 70 +++- src/platform/editorSettings.ts | 9 + src/platform/icls.test.ts | 166 ++++++++++ src/platform/icls.ts | 309 ++++++++++++++++++ src/platform/locales/text/zh.json | 13 +- src/workbench/quickSwitch.ts | 8 +- 17 files changed, 950 insertions(+), 57 deletions(-) create mode 100644 src/features/settings/__tests__/colorScheme.test.tsx create mode 100644 src/platform/__fixtures__/colorSchemes/daylight.icls create mode 100644 src/platform/__fixtures__/colorSchemes/forest.icls create mode 100644 src/platform/icls.test.ts create mode 100644 src/platform/icls.ts diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 434f5a30..bf4bc91b 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -342,6 +342,15 @@ impl Default for AppearanceSettings { } } +/// Imported editor colour scheme on top of a built-in base. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct CustomColorScheme { + pub name: String, + pub parent: String, + pub attributes: BTreeMap, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct EditorSettings { @@ -357,6 +366,8 @@ pub struct EditorSettings { pub format_on_save: bool, /// Editor colour scheme id; `theme-default` follows the IDE theme. pub color_scheme: String, + /// Imported editor schemes, stored in user settings. + pub custom_schemes: BTreeMap, /// Color Scheme edits: scheme id → attribute id → override. pub color_overrides: BTreeMap>, /// Color Scheme › Language Defaults › Semantic highlighting: local variables and @@ -404,6 +415,7 @@ impl Default for EditorSettings { format_on_save: false, color_scheme: "theme-default".into(), color_overrides: BTreeMap::new(), + custom_schemes: BTreeMap::new(), semantic_highlighting: false, rainbow_brackets: false, ligatures: false, diff --git a/crates/ide-workspace/src/settings.rs b/crates/ide-workspace/src/settings.rs index 62abb99d..3b278a0b 100644 --- a/crates/ide-workspace/src/settings.rs +++ b/crates/ide-workspace/src/settings.rs @@ -27,6 +27,12 @@ pub enum SettingsScope { Project, } +fn remove_custom_schemes(layer: &mut Json) { + if let Some(editor) = layer.get_mut("editor").and_then(Json::as_object_mut) { + editor.remove("customSchemes"); + } +} + fn defaults_json() -> Json { serde_json::to_value(Settings::default()).expect("settings serialize") } @@ -178,10 +184,12 @@ impl SettingsStore { fn compute(&self) -> Settings { let mut merged = defaults_json(); - for (_, file) in self.layers() { - if let Some(layer) = read_layer(&file) - && let Err(e) = try_merge(&mut merged, layer) - { + for (scope, file) in self.layers() { + let Some(mut layer) = read_layer(&file) else { continue }; + if scope == SettingsScope::Project { + remove_custom_schemes(&mut layer); + } + if let Err(e) = try_merge(&mut merged, layer) { tracing::warn!("{} has invalid settings, ignoring it: {e}", file.display()); } } @@ -205,6 +213,13 @@ impl SettingsStore { /// `null` removes the key from that layer. Unknown keys and values of the /// wrong type are rejected with `InvalidInput`; the file is left untouched. pub fn set(&self, scope: SettingsScope, key: &str, value: serde_json::Value) -> IdeResult { + if scope == SettingsScope::Project + && (key == "editor.customSchemes" + || key.starts_with("editor.customSchemes.") + || (key == "editor" && value.get("customSchemes").is_some())) + { + return Err(IdeError::invalid("custom color schemes belong to user settings")); + } let file = self.file(scope).ok_or_else(|| IdeError::invalid("no project is open"))?.to_path_buf(); let segments: Vec<&str> = key.split('.').collect(); if segments.iter().any(|s| s.is_empty()) { @@ -242,7 +257,10 @@ impl SettingsStore { for (layer_scope, layer_file) in self.layers() { let is_target = layer_scope == scope; let layer = if is_target { parse_layer(&new_text).ok() } else { read_layer(&layer_file) }; - let Some(layer) = layer else { continue }; + let Some(mut layer) = layer else { continue }; + if layer_scope == SettingsScope::Project { + remove_custom_schemes(&mut layer); + } if let Err(e) = try_merge(&mut merged, layer) && is_target { @@ -316,6 +334,38 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn custom_color_schemes_are_user_settings_and_survive_reload() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = tmp.path().join("cfg"); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(proj.join(".openidea")).unwrap(); + let store = SettingsStore::load(&cfg, Some(&proj)); + let schemes = json!({"custom-forest": {"name": "Forest", "parent": "dark", "attributes": { + "keyword": {"foreground": "#99dd77", "bold": true} + }}}); + store.set(SettingsScope::Global, "editor.customSchemes", schemes.clone()).unwrap(); + store.set(SettingsScope::Global, "editor.colorScheme", json!("custom-forest")).unwrap(); + let loaded = SettingsStore::load(&cfg, Some(&proj)).effective(); + assert_eq!(loaded.editor.color_scheme, "custom-forest"); + assert_eq!(serde_json::to_value(&loaded.editor.custom_schemes).unwrap(), schemes); + assert!(store.set(SettingsScope::Project, "editor.customSchemes", schemes.clone()).is_err()); + assert!( + store + .set(SettingsScope::Project, "editor.customSchemes.custom-other", schemes["custom-forest"].clone()) + .is_err() + ); + assert!(store.set(SettingsScope::Project, "editor", json!({"customSchemes": schemes})).is_err()); + std::fs::write( + proj.join(".openidea/settings.toml"), + "[editor.customSchemes.custom-forest]\nname = \"Project\"\nparent = \"light\"\nattributes = {}\n", + ) + .unwrap(); + assert_eq!(store.reload().editor.custom_schemes["custom-forest"].name, "Forest"); + store.set(SettingsScope::Global, "editor.customSchemes", json!({})).unwrap(); + assert!(SettingsStore::load(&cfg, Some(&proj)).effective().editor.custom_schemes.is_empty()); + } + #[test] fn layering_and_fallback() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/bindings.ts b/src/bindings.ts index b34885d0..501a2116 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1992,6 +1992,23 @@ export type CreatePullRequest = { push: boolean, }; +/** Imported editor colour scheme on top of a built-in base. */ +export type CustomColorScheme = CustomColorScheme_Serialize | CustomColorScheme_Deserialize; + +/** Imported editor colour scheme on top of a built-in base. */ +export type CustomColorScheme_Deserialize = { + name: string, + parent: string, + attributes: { [key in string]: TextAttributesOverride_Deserialize }, +}; + +/** Imported editor colour scheme on top of a built-in base. */ +export type CustomColorScheme_Serialize = { + name: string, + parent: string, + attributes: { [key in string]: TextAttributesOverride_Serialize }, +}; + /** A named profile made with Copy. */ export type CustomInspectionProfile = { name?: string, @@ -2738,6 +2755,8 @@ export type EditorSettings_Deserialize = { formatOnSave?: boolean, /** Editor colour scheme id; `theme-default` follows the IDE theme. */ colorScheme?: string, + /** Imported editor schemes, stored in user settings. */ + customSchemes?: { [key in string]: CustomColorScheme_Deserialize }, /** Color Scheme edits: scheme id → attribute id → override. */ colorOverrides?: { [key in string]: { [key in string]: TextAttributesOverride_Deserialize } }, /** @@ -2785,6 +2804,8 @@ export type EditorSettings_Serialize = { formatOnSave: boolean, /** Editor colour scheme id; `theme-default` follows the IDE theme. */ colorScheme: string, + /** Imported editor schemes, stored in user settings. */ + customSchemes: { [key in string]: CustomColorScheme_Serialize }, /** Color Scheme edits: scheme id → attribute id → override. */ colorOverrides: { [key in string]: { [key in string]: TextAttributesOverride_Serialize } }, /** diff --git a/src/editor/core/colorSchemeRuntime.ts b/src/editor/core/colorSchemeRuntime.ts index b94600d4..d6aa7221 100644 --- a/src/editor/core/colorSchemeRuntime.ts +++ b/src/editor/core/colorSchemeRuntime.ts @@ -16,7 +16,7 @@ export function installColorScheme(): () => void { const apply = () => { const settings = useProject.getState().settings; const prefs = editorPrefs(settings); - const css = schemeCss(settings?.editor?.colorScheme, prefs.colorOverrides); + const css = schemeCss(settings?.editor?.colorScheme, prefs.colorOverrides, undefined, prefs.customSchemes); if (el.textContent !== css) el.textContent = css; for (const [name, value] of Object.entries(fontWeightVars(prefs.fontWeight, prefs.boldFontWeight))) if (root.style.getPropertyValue(name) !== value) root.style.setProperty(name, value); diff --git a/src/features/settings/__tests__/colorScheme.test.tsx b/src/features/settings/__tests__/colorScheme.test.tsx new file mode 100644 index 00000000..4d9a40c4 --- /dev/null +++ b/src/features/settings/__tests__/colorScheme.test.tsx @@ -0,0 +1,116 @@ +import { readFileSync } from "node:fs"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { executeAction } from "@/platform/actions"; +import { useProject } from "@/platform/project"; +import { DEFAULT_SETTINGS } from "../model/schema"; +import { openSettings, stage, stagedValue, useSettingsDialog } from "../model/staging"; +import { PageContext } from "../components/context"; +import { COLOR_SCHEME_PAGE } from "../pages/editor/colorScheme"; + +const mocks = vi.hoisted(() => ({ + open: vi.fn(), + save: vi.fn(), + read: vi.fn(), + write: vi.fn(), + ask: vi.fn(), + confirm: vi.fn(), + error: vi.fn(), +})); +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.open, save: mocks.save })); +vi.mock("@/workbench/messages", () => ({ ask: mocks.ask, confirm: mocks.confirm, showError: mocks.error })); +vi.mock("@/platform/ipc", async (original) => { + const actual = await original(); + return { ...actual, commands: { ...actual.commands, fsReadText: mocks.read, fsCreateFileBytes: mocks.write } }; +}); +const value = (key: string) => stagedValue(useSettingsDialog.getState(), "global", `editor.${key}`); +const run = (name: string) => + act(async () => { + await executeAction(`editor.colorScheme.${name}`); + }); + +beforeEach(() => { + vi.resetAllMocks(); + mocks.open.mockResolvedValue("/schemes/forest.icls"); + mocks.save.mockResolvedValue("/schemes/export.icls"); + mocks.read.mockResolvedValue({ + status: "ok", + data: readFileSync("src/platform/__fixtures__/colorSchemes/forest.icls", "utf8"), + }); + mocks.write.mockResolvedValue({ status: "ok", data: null }); + useProject.setState({ settings: structuredClone(DEFAULT_SETTINGS), project: null }); + openSettings("editor.colorScheme"); + const Page = COLOR_SCHEME_PAGE.component!; + render( + + + , + ); +}); + +describe("scheme actions", () => { + it("imports and selects in global scope, exports and confirms deletion", async () => { + await run("import"); + const id = value("colorScheme") as string; + expect(id).toMatch(/^custom-/); + expect(screen.getByRole("status")).toHaveTextContent("Applied 16 values; skipped 2"); + expect(Object.values(useSettingsDialog.getState().staged).every((s) => s.scope === "global")).toBe(true); + await run("export"); + expect(mocks.write).toHaveBeenCalledWith("/schemes/export.icls", expect.any(Array)); + expect(new TextDecoder().decode(new Uint8Array(mocks.write.mock.calls[0]![1]))).toContain( + 'name="Forest & Moss"', + ); + mocks.confirm.mockResolvedValueOnce(false); + await run("delete"); + expect(value("colorScheme")).toBe(id); + mocks.confirm.mockResolvedValueOnce(true); + await run("delete"); + expect(value("colorScheme")).toBe("theme-default"); + expect(value("customSchemes")).toEqual({}); + }); + + it("accepts file paths from action callers and keeps the same import/export flow", async () => { + await act(async () => { + await executeAction("editor.colorScheme.import", "/chosen/custom.icls"); + }); + expect(mocks.open).not.toHaveBeenCalled(); + expect(mocks.read).toHaveBeenCalledWith("/chosen/custom.icls"); + expect(value("colorScheme")).toMatch(/^custom-/); + await act(async () => { + await executeAction("editor.colorScheme.export", "/chosen/custom-export.icls"); + }); + expect(mocks.save).not.toHaveBeenCalled(); + expect(mocks.write).toHaveBeenCalledWith("/chosen/custom-export.icls", expect.any(Array)); + }); + + it("supports cancel, keep both and overwrite collisions", async () => { + await run("import"); + const first = value("colorScheme"); + mocks.ask.mockResolvedValueOnce({ button: "cancel" }); + await run("import"); + expect(value("colorScheme")).toBe(first); + mocks.ask.mockResolvedValueOnce({ button: "both" }); + await run("import"); + expect(Object.values(value("customSchemes") as Record).map((s) => s.name)).toEqual([ + "Forest & Moss", + "Forest & Moss (2)", + ]); + act(() => + stage("editor.colorScheme", "global", "editor.colorOverrides", { + [first as string]: { keyword: { foreground: "#ff0000" } }, + }), + ); + mocks.ask.mockResolvedValueOnce({ button: "overwrite" }); + await run("import"); + expect(value("colorScheme")).toBe(first); + expect(value("colorOverrides")).toEqual({}); + expect(Object.keys(value("customSchemes") as object)).toHaveLength(2); + }); + + it("shows a malformed-file error without staging any changes", async () => { + mocks.read.mockResolvedValueOnce({ status: "ok", data: '' }); + await run("import"); + await waitFor(() => expect(mocks.error).toHaveBeenCalledWith("Color Scheme", "Invalid .icls color scheme")); + expect(useSettingsDialog.getState().staged).toEqual({}); + }); +}); diff --git a/src/features/settings/pages/behavior.tsx b/src/features/settings/pages/behavior.tsx index 5b0a5fb6..f44d76c7 100644 --- a/src/features/settings/pages/behavior.tsx +++ b/src/features/settings/pages/behavior.tsx @@ -10,7 +10,7 @@ import { useId, useRef, useState, type ReactNode } from "react"; import clsx from "clsx"; import { shortcutLabel } from "@/platform/keymap"; import { isTauri } from "@/platform/ipc"; -import { SCHEMES as EDITOR_SCHEMES } from "@/platform/colorScheme"; +import { SCHEMES as EDITOR_SCHEMES, listSchemes, customSchemes } from "@/platform/colorScheme"; import { formatTimestamp } from "@/platform/dateFormat"; import { Checkbox, IconButton, Input, Popup, type SelectOption, T } from "@/ui"; import { NOTIFICATION_GROUPS } from "@/workbench/notificationStore"; @@ -117,7 +117,8 @@ const SCHEMES: SelectOption[] = [ function ColorSchemeRow() { const [scheme, setScheme] = useSetting("editor.colorScheme"); - const options = SCHEMES.some((s) => s.value === scheme) ? SCHEMES : [...SCHEMES, { value: scheme, label: scheme }]; + const [customs] = useSetting("editor.customSchemes", "global"); + const options = [SCHEMES[0]!, ...listSchemes(customSchemes(customs)).map((s) => ({ value: s.id, label: s.name }))]; return ( diff --git a/src/features/settings/pages/editor/Editor.module.css b/src/features/settings/pages/editor/Editor.module.css index a2dfc329..2f76363b 100644 --- a/src/features/settings/pages/editor/Editor.module.css +++ b/src/features/settings/pages/editor/Editor.module.css @@ -148,6 +148,7 @@ /* Color Scheme page */ .schemeRow { display: flex; + flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 10px; diff --git a/src/features/settings/pages/editor/colorScheme.tsx b/src/features/settings/pages/editor/colorScheme.tsx index 6b0557ae..75aa54df 100644 --- a/src/features/settings/pages/editor/colorScheme.tsx +++ b/src/features/settings/pages/editor/colorScheme.tsx @@ -4,32 +4,32 @@ * italic / effects editor, and a live preview. Edits are staged in * `editor.colorOverrides[scheme][attribute]`; `editor/core` injects them. */ -import { useMemo, useState, type CSSProperties } from "react"; +import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { Button, Checkbox, Input, Tree, type SelectOption, T } from "@/ui"; -import { fontWeightVars, type TextAttributesOverride } from "@/platform/editorSettings"; +import { fontWeightVars, type CustomColorSchemes, type TextAttributesOverride } from "@/platform/editorSettings"; import { ATTRIBUTES, SCHEMES, - boldWeight, + overrideDeclarations, effectiveScheme, - schemeTokens, + resolvedSchemeTokens, + listSchemes, + customSchemes, type AttributeDef, - type SchemeId, } from "@/platform/colorScheme"; import { HEX_COLOR } from "../../model/schema"; import { Row, Select } from "../../components/Form"; import { useSetting } from "../../components/context"; import { editorPage } from "./kit"; import styles from "./Editor.module.css"; +import { executeAction, registerActions } from "@/platform/actions"; +import { call, commands } from "@/platform/ipc"; +import { ask, confirm, showError } from "@/workbench/messages"; +import { parseIcls, importScheme, deleteScheme, exportIcls } from "@/platform/icls"; import { tx } from "@/platform/i18n"; type Overrides = Record>; -const SCHEME_OPTIONS: SelectOption[] = [ - { value: "theme-default", label: "Theme default" }, - ...SCHEMES.map((s) => ({ value: s.id, label: s.name })), -]; - interface Node { key: string; label: string; @@ -56,9 +56,14 @@ function buildTree(): Node[] { const TREE = buildTree(); /** CSS custom properties of a scheme plus pending overrides (for the preview). */ -function previewVars(id: SchemeId, o: Record | undefined): CSSProperties { +function previewVars( + id: string, + o: Record | undefined, + customs: CustomColorSchemes, +): CSSProperties { const vars: Record = {}; - for (const [k, v] of schemeTokens(id)) + const tokens = resolvedSchemeTokens(id, customs); + for (const [k, v] of tokens) if ( k.startsWith("--syn-") || k.startsWith("--fg-") || @@ -67,21 +72,8 @@ function previewVars(id: SchemeId, o: Record | u k === "--caret" ) vars[k] = v; - vars["--bg-editor"] = schemeTokens(id).get("--bg-editor") ?? schemeTokens(id).get("--bg-island") ?? ""; - for (const attr of ATTRIBUTES) { - const ov = o?.[attr.id]; - if (!ov) continue; - if (ov.foreground && attr.fg) vars[attr.fg] = ov.foreground; - if (ov.background && attr.bg) vars[attr.bg] = ov.background; - if (attr.token) { - if (ov.bold !== undefined) vars[`--syn-${attr.token}-bold`] = boldWeight(ov.bold); - if (ov.italic !== undefined) vars[`--syn-${attr.token}-italic`] = ov.italic ? "italic" : "normal"; - if (ov.background) vars[`--syn-${attr.token}-bg`] = ov.background; - if (ov.effect && ov.effect !== "none") - vars[`--syn-${attr.token}-effect`] = - `${ov.effect === "strikeout" ? "line-through" : ov.effect === "wave" ? "underline wavy" : "underline"} ${ov.effectColor ?? "currentColor"}`; - } - } + vars["--bg-editor"] = tokens.get("--bg-editor") ?? tokens.get("--bg-island") ?? ""; + for (const [key, value] of overrideDeclarations(o)) vars[key] = value; return vars as CSSProperties; } @@ -270,17 +262,21 @@ function ColorRow({ } function ColorSchemePage() { - const [scheme, setScheme] = useSetting("editor.colorScheme"); - const [overrides, setOverrides] = useSetting("editor.colorOverrides"); + const [scheme, setScheme] = useSetting("editor.colorScheme", "global"); + const [overrides, setOverrides] = useSetting("editor.colorOverrides", "global"); const [weight] = useSetting("editor.fontWeight"); const [boldFontWeight] = useSetting("editor.boldFontWeight"); + const [rawCustoms, setCustoms] = useSetting("editor.customSchemes", "global"); + const customs = useMemo(() => customSchemes(rawCustoms), [rawCustoms]); + const [report, setReport] = useState(""); + const [busy, setBusy] = useState(false); const [selected, setSelected] = useState("keyword"); - const id = effectiveScheme(scheme); + const id = effectiveScheme(scheme, undefined, customs); const mine = overrides?.[id]; const attr = ATTRIBUTES.find((a) => a.id === selected); const vars = useMemo( - () => ({ ...previewVars(id, mine), ...fontWeightVars(weight, boldFontWeight) }) as CSSProperties, - [id, mine, weight, boldFontWeight], + () => ({ ...previewVars(id, mine, customs), ...fontWeightVars(weight, boldFontWeight) }) as CSSProperties, + [id, mine, customs, weight, boldFontWeight], ); const update = (attrId: string, v: TextAttributesOverride | undefined) => { const next: Overrides = { ...(overrides ?? {}) }; @@ -291,13 +287,128 @@ function ColorSchemePage() { else delete next[id]; setOverrides(next); }; - const schemeName = SCHEMES.find((s) => s.id === id)?.name ?? id; + const schemes = listSchemes(customs); + const schemeName = schemes.find((s) => s.id === id)?.name ?? id; + const options: SelectOption[] = [ + { value: "theme-default", label: "Theme default" }, + ...schemes.map((s) => ({ value: s.id, label: s.name })), + ]; + const handlers = useRef Promise>>({}); + useEffect(() => { + handlers.current = { + import: async (file) => { + const { open } = await import("@tauri-apps/plugin-dialog"); + const path = + file ?? + (await open({ + multiple: false, + filters: [{ name: tx("IntelliJ Color Scheme"), extensions: ["icls"] }], + })); + if (!path) return; + const parsed = parseIcls(await call(commands.fsReadText(path))); + let overwrite = false; + if (schemes.some((s) => s.name === parsed.scheme.name)) { + const canOverwrite = Object.values(customs).some((s) => s.name === parsed.scheme.name); + const { button } = await ask({ + title: tx("Import Scheme"), + body: tx`A scheme named '${parsed.scheme.name}' already exists.`, + buttons: [ + ...(canOverwrite ? [{ id: "overwrite", label: tx("Overwrite") }] : []), + { id: "both", label: tx("Keep Both"), primary: true }, + ], + }); + if (button !== "overwrite" && button !== "both") return; + overwrite = button === "overwrite"; + } + const next = importScheme(customs, parsed.scheme, overwrite); + setCustoms(next.customs); + if (overwrite) + setOverrides(Object.fromEntries(Object.entries(overrides ?? {}).filter(([key]) => key !== next.id))); + setScheme(next.id); + setReport(tx`Applied ${parsed.applied} values; skipped ${parsed.skipped} unsupported or invalid values.`); + }, + export: async (file) => { + const { save } = await import("@tauri-apps/plugin-dialog"); + const path = + file ?? + (await save({ + defaultPath: `${schemeName.replace(/[/\\:*?"<>|]/g, "_")}.icls`, + filters: [{ name: tx("IntelliJ Color Scheme"), extensions: ["icls"] }], + })); + if (!path) return; + const bytes = Array.from(new TextEncoder().encode(exportIcls(id, customs, mine))); + // The save dialog already confirmed replacing an existing file. + await call(commands.fsDelete(path)).catch(() => {}); + await call(commands.fsCreateFileBytes(path, bytes)); + setReport(tx("Scheme exported.")); + }, + delete: async () => { + if ( + !customs[id] || + !(await confirm({ + title: tx("Delete Scheme"), + message: tx`Delete color scheme '${schemeName}'?`, + ok: tx("Delete"), + })) + ) + return; + const next = deleteScheme(customs, overrides ?? {}, scheme, id); + setCustoms(next.customs); + setOverrides(next.overrides); + setScheme(next.selected); + setReport(""); + }, + }; + }); + const pending = useRef(false); + useEffect( + () => + registerActions( + (["import", "export", "delete"] as const).map((name) => ({ + id: `editor.colorScheme.${name}`, + title: name === "import" ? "Import Scheme" : name === "export" ? "Export Scheme" : "Delete Scheme", + category: "Editor", + run: async ({ arg }) => { + if (pending.current) return; + pending.current = true; + setBusy(true); + try { + await handlers.current[name]!(typeof arg === "string" ? arg : undefined); + } catch (error) { + await showError( + tx("Color Scheme"), + error instanceof Error && error.message === "Invalid .icls color scheme" + ? tx("Invalid .icls color scheme") + : String(error), + ); + } finally { + pending.current = false; + setBusy(false); + } + }, + })), + ), + [], + ); return (
- + + +
+ {report && ( +
+ {report} +
+ )} {scheme === "theme-default" && (
{tx`Follows the IDE theme: currently ${schemeName}.`}
)} @@ -364,6 +480,7 @@ export const COLOR_SCHEME_PAGE = editorPage({ id: "editor.colorScheme", title: "Color Scheme", order: 23, + projectScope: false, keywords: [ "colors", "syntax highlighting", diff --git a/src/platform/__fixtures__/colorSchemes/daylight.icls b/src/platform/__fixtures__/colorSchemes/daylight.icls new file mode 100644 index 00000000..95b126d4 --- /dev/null +++ b/src/platform/__fixtures__/colorSchemes/daylight.icls @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/platform/__fixtures__/colorSchemes/forest.icls b/src/platform/__fixtures__/colorSchemes/forest.icls new file mode 100644 index 00000000..b38a1aae --- /dev/null +++ b/src/platform/__fixtures__/colorSchemes/forest.icls @@ -0,0 +1,21 @@ + + idea + + + + + + + + + + + + + diff --git a/src/platform/clientSettings.ts b/src/platform/clientSettings.ts index 77f50437..b2c2c83b 100644 --- a/src/platform/clientSettings.ts +++ b/src/platform/clientSettings.ts @@ -21,6 +21,7 @@ export const CLIENT_SETTINGS_KEYS = [ "editor.boldFontWeight", "editor.colorScheme", "editor.colorOverrides", + "editor.customSchemes", "editor.semanticHighlighting", "editor.rainbowBrackets", "editor.softWrap", diff --git a/src/platform/colorScheme.ts b/src/platform/colorScheme.ts index 3da62ea9..8542617e 100644 --- a/src/platform/colorScheme.ts +++ b/src/platform/colorScheme.ts @@ -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; + if (typeof v.name !== "string" || !v.name.trim() || (v.parent !== "light" && v.parent !== "dark")) continue; + const attributes: Record = {}; + if (v.attributes && typeof v.attributes === "object") { + for (const attr of ATTRIBUTES) { + const a = (v.attributes as Record)[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> | 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"); diff --git a/src/platform/editorSettings.ts b/src/platform/editorSettings.ts index 1f1b3f99..4443d889 100644 --- a/src/platform/editorSettings.ts +++ b/src/platform/editorSettings.ts @@ -15,6 +15,14 @@ export interface TextAttributesOverride { effectColor?: string; } +export interface CustomColorScheme { + name: string; + parent: "dark" | "light"; + attributes: Record; +} + +export type CustomColorSchemes = Record; + 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>, /** Color Scheme › Language Defaults › Semantic highlighting (rainbow locals / parameters). */ semanticHighlighting: false, diff --git a/src/platform/icls.test.ts b/src/platform/icls.test.ts new file mode 100644 index 00000000..675a23f2 --- /dev/null +++ b/src/platform/icls.test.ts @@ -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([ + "", + "', + "", + '', + ']>', + ])("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.parent, + ).toBe(expected); + }); + + it("ignores CSS injection on import and from edited settings", () => { + const { scheme, applied, skipped } = parseIcls( + '', + ); + 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 }); + } + }); +}); diff --git a/src/platform/icls.ts b/src/platform/icls.ts new file mode 100644 index 00000000..3e6269f5 --- /dev/null +++ b/src/platform/icls.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + "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 = {}; + 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>, + 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, ">"); +export function exportIcls( + id: string, + customs: CustomColorSchemes, + overrides: Record = {}, +): 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(` `); + } + const parent = custom?.parent ?? (isBuiltinTheme(id) ? themeKind(id) : "dark"); + return `\n \n${colors.join("\n")}\n \n \n${attributes.join("\n")}\n \n\n`; +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 01c22f78..3e6b4ead 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "覆盖" } diff --git a/src/workbench/quickSwitch.ts b/src/workbench/quickSwitch.ts index bb1485e0..23ec5089 100644 --- a/src/workbench/quickSwitch.ts +++ b/src/workbench/quickSwitch.ts @@ -3,7 +3,8 @@ * toolbar's gear menu and View › Quick Switch Scheme… (⌃`). */ import { call, commands, isTauri } from "@/platform/ipc"; -import { SCHEMES } from "@/platform/colorScheme"; +import { listSchemes } from "@/platform/colorScheme"; +import { editorPrefs } from "@/platform/editorSettings"; import { PRESET_TITLES, useKeymap } from "@/platform/keymap"; import { useProject } from "@/platform/project"; import type { MenuItem } from "@/ui"; @@ -40,7 +41,8 @@ export function keymapItems(keymapId: string, customKeymaps: string[]): MenuItem } export function colorSchemeItems(current: string | undefined): MenuItem[] { - const value = current && SCHEMES.some((s) => s.id === current) ? current : "theme-default"; + const schemes = listSchemes(editorPrefs().customSchemes); + const value = current && schemes.some((s) => s.id === current) ? current : "theme-default"; return [ { id: "scheme.theme-default", @@ -48,7 +50,7 @@ export function colorSchemeItems(current: string | undefined): MenuItem[] { checked: value === "theme-default", run: () => void setGlobal("editor.colorScheme", "theme-default", "color scheme"), }, - ...SCHEMES.map((s) => ({ + ...schemes.map((s) => ({ id: `scheme.${s.id}`, label: s.name, checked: s.id === value,