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,