diff --git a/crates/ide-plugin/src/lib.rs b/crates/ide-plugin/src/lib.rs index 8db39961..af60d2be 100644 --- a/crates/ide-plugin/src/lib.rs +++ b/crates/ide-plugin/src/lib.rs @@ -20,7 +20,9 @@ mod sandbox; mod theme; mod types; -pub use manifest::{API_VERSION, CommandDecl, Contributions, FileDecl, MANIFEST_FILE, Permission, PluginManifest}; +pub use manifest::{ + API_VERSION, CommandDecl, Contributions, FileDecl, MANIFEST_FILE, Permission, PluginManifest, ThemeDecl, +}; pub use sandbox::resolve_in_root; pub use theme::{ThemeDef, ThemeKind}; pub use types::*; @@ -627,7 +629,10 @@ fn static_contributions(dir: &Path, m: &PluginManifest) -> Result, #[serde(default)] - pub themes: Vec, + pub themes: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -83,6 +83,16 @@ pub struct FileDecl { pub path: PathBuf, } +/// A contributed theme: the UI theme file plus an optional editor colour scheme +/// whose tokens are merged over it (IntelliJ's `.theme.json` + `.icls`). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct ThemeDecl { + pub path: PathBuf, + #[serde(default)] + pub editor_scheme: Option, +} + impl PluginManifest { pub fn parse(src: &str) -> Result { let manifest: Self = toml::from_str(src).map_err(|e| e.to_string())?; @@ -123,8 +133,9 @@ impl PluginManifest { return Err(format!("command {:?} has an empty title", c.id)); } } - for f in self.contributes.languages.iter().chain(&self.contributes.themes) { - check_relative(&f.path, "contributes path")?; + let theme_files = self.contributes.themes.iter().flat_map(|t| std::iter::once(&t.path).chain(&t.editor_scheme)); + for path in self.contributes.languages.iter().map(|f| &f.path).chain(theme_files) { + check_relative(path, "contributes path")?; } Ok(()) } @@ -133,7 +144,10 @@ impl PluginManifest { pub fn files(&self) -> Vec<&Path> { let mut files: Vec<&Path> = self.wasm.iter().map(PathBuf::as_path).collect(); files.extend(self.contributes.languages.iter().map(|f| f.path.as_path())); - files.extend(self.contributes.themes.iter().map(|f| f.path.as_path())); + for t in &self.contributes.themes { + files.push(t.path.as_path()); + files.extend(t.editor_scheme.as_deref()); + } files } @@ -202,6 +216,7 @@ mod tests { [[contributes.themes]] path = "themes/dark.json" + editor-scheme = "themes/dark.scheme.json" "#; #[test] @@ -210,7 +225,10 @@ mod tests { assert_eq!(m.id, "hello"); assert_eq!(m.permissions, vec![Permission::ProjectRead, Permission::EditorWrite]); assert_eq!(m.contributes.commands[0].category.as_deref(), Some("Hello")); - assert_eq!(m.files(), vec![Path::new("dist/plugin.wasm"), Path::new("themes/dark.json")]); + assert_eq!( + m.files(), + vec![Path::new("dist/plugin.wasm"), Path::new("themes/dark.json"), Path::new("themes/dark.scheme.json")] + ); assert!(m.has(Permission::EditorWrite) && !m.has(Permission::EditorRead)); } @@ -242,6 +260,8 @@ mod tests { assert!(err(&base(dup, "ok", "0.1")).contains("duplicate command")); let traversal = "[[contributes.languages]]\npath = \"a/../../x.toml\""; assert!(err(&base(traversal, "ok", "0.1")).contains("relative path")); + let scheme_escape = "[[contributes.themes]]\npath = \"t.json\"\neditor-scheme = \"../s.json\""; + assert!(err(&base(scheme_escape, "ok", "0.1")).contains("relative path")); assert!(err("id = \"ok\"\nname = \"N\"\nversion = \"1.0\"\nopenidea-api = \"0.1\"").contains("version")); assert!(err("id = \"ok\"\nname = \" \"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"").contains("name")); } diff --git a/crates/ide-plugin/src/theme.rs b/crates/ide-plugin/src/theme.rs index 8e7f1b9d..0d4e65e7 100644 --- a/crates/ide-plugin/src/theme.rs +++ b/crates/ide-plugin/src/theme.rs @@ -1,4 +1,5 @@ //! Theme contributions: design-token overrides applied as CSS variables on `:root`. +//! Theme and editor-scheme files are JSON that may contain `//` and `/* */` comments. use serde::{Deserialize, Serialize}; use specta::Type; @@ -24,13 +25,28 @@ pub struct ThemeDef { pub tokens: BTreeMap, } +/// `{"name"?, "tokens": {...}}`: an editor colour scheme merged over a theme. +#[derive(Deserialize)] +struct EditorScheme { + tokens: BTreeMap, +} + impl ThemeDef { pub fn parse(src: &str) -> Result { - let theme: Self = serde_json::from_str(src).map_err(|e| format!("invalid theme JSON: {e}"))?; + let theme: Self = + serde_json::from_str(&strip_json_comments(src)).map_err(|e| format!("invalid theme JSON: {e}"))?; theme.validate()?; Ok(theme) } + /// Merges an editor colour scheme file over the theme's tokens (the scheme wins). + pub fn merge_editor_scheme(&mut self, src: &str) -> Result<(), String> { + let scheme: EditorScheme = + serde_json::from_str(&strip_json_comments(src)).map_err(|e| format!("invalid editor scheme JSON: {e}"))?; + self.tokens.extend(scheme.tokens); + self.validate() + } + pub fn validate(&self) -> Result<(), String> { crate::manifest::validate_command_id(&self.id).map_err(|e| e.replace("command id", "theme id"))?; if self.name.trim().is_empty() { @@ -55,6 +71,53 @@ impl ThemeDef { } } +/// Blanks out `//` and `/* */` comments outside strings, keeping newlines so +/// parse errors still point at the right line. +fn strip_json_comments(src: &str) -> String { + let mut out = String::with_capacity(src.len()); + let mut chars = src.chars().peekable(); + let (mut in_str, mut escaped) = (false, false); + while let Some(c) = chars.next() { + if in_str { + out.push(c); + if escaped { + escaped = false; + } else if c == '\\' { + escaped = true; + } else if c == '"' { + in_str = false; + } + continue; + } + match (c, chars.peek()) { + ('/', Some('/')) => { + if chars.by_ref().any(|c| c == '\n') { + out.push('\n'); + } + } + ('/', Some('*')) => { + chars.next(); + let mut prev = ' '; + for c in chars.by_ref() { + if c == '\n' { + out.push('\n'); + } + if prev == '*' && c == '/' { + break; + } + prev = c; + } + out.push(' '); + } + _ => { + in_str = c == '"'; + out.push(c); + } + } + } + out +} + /// Values are plain CSS values (colours, lengths, font stacks). Anything that /// could break out of the declaration or fetch resources is rejected. fn is_safe_value(v: &str) -> bool { @@ -95,4 +158,34 @@ mod tests { assert!(mk("--bg", " ").is_err()); assert!(ThemeDef::parse("{\"id\":\"x\"}").is_err()); } + + #[test] + fn allows_comments_outside_strings() { + let src = r##"{ + // line comment + "id": "c", "name": "C // not a comment", "kind": "dark", + /* block + comment */ + "tokens": { "--bg-app": "#000" /* inline */, "--font-ui": "\"a/*b\", sans-serif" } + }"##; + let t = ThemeDef::parse(src).unwrap(); + assert_eq!(t.name, "C // not a comment"); + assert_eq!(t.tokens["--font-ui"], "\"a/*b\", sans-serif"); + assert!(ThemeDef::parse("{\n // x\n \"id\": 1\n}").unwrap_err().contains("line 3")); + } + + #[test] + fn merges_editor_scheme_over_theme() { + let mut t = ThemeDef::parse( + r##"{"id":"m","name":"M","kind":"dark","tokens":{"--bg-app":"#111","--syn-keyword":"#222"}}"##, + ) + .unwrap(); + t.merge_editor_scheme(r##"{"name":"M", /* editor */ "tokens":{"--syn-keyword":"#333","--caret":"#fff"}}"##) + .unwrap(); + assert_eq!(t.tokens["--bg-app"], "#111"); + assert_eq!(t.tokens["--syn-keyword"], "#333"); + assert_eq!(t.tokens["--caret"], "#fff"); + assert!(t.clone().merge_editor_scheme(r#"{"tokens":{"--caret":"url(x)"}}"#).is_err()); + assert!(t.merge_editor_scheme(r#"{"name":"no tokens"}"#).is_err()); + } } diff --git a/crates/ide-plugin/tests/host.rs b/crates/ide-plugin/tests/host.rs index 85757421..c56414ef 100644 --- a/crates/ide-plugin/tests/host.rs +++ b/crates/ide-plugin/tests/host.rs @@ -242,6 +242,42 @@ fn contribution_only_plugins() { assert_eq!(f.status("language-toy"), PluginStatus::Disabled); } +#[test] +fn theme_template_installs_with_its_editor_scheme() { + let f = fixture(Limits::default()); + let info = f.host.install_from_dir(&repo().join("templates/theme-plugin")).unwrap(); + assert_eq!(info.status, PluginStatus::Static); + assert!(!info.has_code); + assert_eq!(info.themes[0].id, "harbor-night"); + assert_eq!(info.themes[0].kind, ThemeKind::Dark); + // The scheme file is copied with the package and merged over the UI tokens. + assert!(f.plugins_dir.join("theme-harbor-night/themes/harbor-night.scheme.json").is_file()); + let active = f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap().unwrap(); + let tokens = &active.theme.tokens; + assert!( + tokens.contains_key("--bg-app") && tokens.contains_key("--syn-keyword") && tokens.contains_key("--ansi-red") + ); + assert!(tokens.len() > 150); +} + +#[test] +fn theme_with_broken_editor_scheme_fails_to_load() { + let f = fixture(Limits::default()); + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("t")).unwrap(); + std::fs::write( + dir.path().join("plugin.toml"), + "id = \"bad-scheme\"\nname = \"B\"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"\n\ + [[contributes.themes]]\npath = \"t/a.json\"\neditor-scheme = \"t/a.scheme.json\"\n", + ) + .unwrap(); + std::fs::write(dir.path().join("t/a.json"), r##"{"id":"a","name":"A","kind":"light","tokens":{}}"##).unwrap(); + std::fs::write(dir.path().join("t/a.scheme.json"), "{\n \"tokens\": { \"--caret\": \"url(x)\" }\n}").unwrap(); + let info = f.host.install_from_dir(dir.path()).unwrap(); + assert_eq!(info.status, PluginStatus::Failed); + assert!(info.error.unwrap_or_default().contains("a.scheme.json")); +} + #[test] fn state_persists_across_restarts() { let f = fixture(Limits::default()); diff --git a/docs/plugins/themes.md b/docs/plugins/themes.md new file mode 100644 index 00000000..a2aadf1f --- /dev/null +++ b/docs/plugins/themes.md @@ -0,0 +1,551 @@ +# Theme plugins + +OpenIDEA ships three built-in themes (Islands Light, Islands Dark, High +Contrast). Everything else comes from **theme plugins**, the same model as +IntelliJ IDEA, where most themes are plugins rather than part of the IDE. + +A theme plugin is data only. Like an IntelliJ theme (a `.theme.json` plus an +optional `.icls` editor scheme) it has no code: no WebAssembly, nothing to +compile, no permissions. + +``` +my-theme/ +├─ plugin.toml # manifest +└─ themes/ + ├─ my-theme.theme.json # UI colours (IntelliJ: .theme.json) + └─ my-theme.scheme.json # editor colours, optional (IntelliJ: .icls) +``` + +The quickest way to start is to copy [`templates/theme-plugin`](../../templates/theme-plugin). +It contains *Harbor Night*, a complete example dark theme that sets every +themeable token. The rest of this page covers the file format, the token +reference, porting themes from IntelliJ IDEA and VS Code, and testing. + +## The manifest + +```toml +id = "theme-harbor-night" # [a-z0-9][a-z0-9_-]*, unique among plugins +name = "Harbor Night Theme" +version = "1.0.0" +description = "..." +authors = ["You "] +openidea-api = "0.1" + +[[contributes.themes]] # one entry per theme; a plugin may ship several +path = "themes/harbor-night.theme.json" +editor-scheme = "themes/harbor-night.scheme.json" # optional +``` + +Paths are relative to the package directory and may not leave it. The other +manifest fields are described in [`plugins/README.md`](../../plugins/README.md). + +## The theme file + +```jsonc +{ + "id": "harbor-night", // [a-z0-9][a-z0-9._-]*, unique within the plugin + "name": "Harbor Night", // shown in Settings › Plugins + "kind": "dark", // "dark" or "light": the built-in base + "tokens": { + "--bg-app": "#14171c", + "--accent": "#2b9e93" + } +} +``` + +- **`tokens`** are the CSS variables of [`src/styles/tokens.css`](../../src/styles/tokens.css), + the IDE's single source of colours. Components never use colour literals, so + every surface, border, text colour, syntax colour and status colour can be + themed through them. The [reference](#token-reference) lists them all. +- **`kind`** picks the built-in base. While the theme is active the IDE runs on + Islands Dark or Islands Light, and every token the theme leaves out keeps + that base's value. Icons also use the light or dark set that matches the + kind. A partial theme still looks consistent, but listing every token keeps + it stable when the IDE's own palette changes. +- Values are plain CSS values: hex (`#rgb`, `#rrggbb`, `#rrggbbaa`), `rgb()`, + `hsl()`, `color-mix()`, `var(--other-token)`, or for the few non-colour + tokens a keyword (`italic`, `normal`, `none`) or shadow list. `url()`, + `image()`, `;`, braces, `<`, `>` and `\` are rejected. +- `//` and `/* */` comments are allowed, as in VS Code theme files. +- Tokens that tokens.css derives from others (for example `--bg-editor` is + `var(--bg-island)`, and the coverage and flame graph colours are mixed from + the status colours) follow automatically. You may still set them. +- Metrics (`--fs-*`, `--h-*`, `--radius-*`) and fonts (`--font-ui`, + `--font-mono`) can be overridden too, but a theme normally leaves them alone + so that compact mode and the user's font settings keep working. + +### The editor colour scheme file + +```jsonc +{ + "name": "Harbor Night", + "tokens": { "--syn-keyword": "#e0a46e", "--caret": "#5ce0d2" } +} +``` + +Its tokens are merged over the theme's, and the scheme wins where both set a +token. By convention it holds the *syntax*, *editor* and *ANSI palette* groups +of the reference, the parts IntelliJ keeps in an `.icls` file. The split only +organises the files: a theme can also put everything in its theme file. + +### How a theme is applied + +The selected theme is injected as one stylesheet on `:root` that outranks the +built-in theme blocks, and `data-theme` is switched to the theme's `kind`. +Three things still layer on top of it: + +- an accent colour picked in *Settings › Appearance* (other than the default), +- an explicit scheme or attribute overrides in *Settings › Editor › Color Scheme*, +- the appearance options that change only metrics (compact mode). + +The selection is remembered. When the plugin is disabled or uninstalled the +built-in theme returns. + +## Building, packaging, installing + +There is no build step. The package *is* the folder. + +1. Copy `templates/theme-plugin` and rename `id`, `name` and the theme files. +2. Edit the colours. Keep `kind` truthful (`light` for light backgrounds). +3. **Install:** *Settings › Plugins › ⚙ › Install Plugin from Disk…* (or the + *Install Plugin from Disk…* action) and pick the package folder. The + manifest and the files it references are copied to + `/plugins//`. +4. **Apply:** in *Settings › Plugins*, select the plugin and press *Apply* next + to the theme. *Use Default Theme* switches back. + +To iterate without reinstalling, put (or symlink) the package in +`/.openidea/plugins//` of an open project. It loads as a *dev* +plugin, and *Reload* (or the *Reload Plugins* action) picks up your edits. + +To share a theme, publish the folder (a Git repository or a zip). Users +install it from disk. + +## Porting a theme + +Only port themes whose licence allows redistribution, keep their licence file, +and credit the original authors in `authors` and `description`. + +### From IntelliJ IDEA (automatic) + +`scripts/theme/import-intellij-theme.ts` converts an IntelliJ `.theme.json`, +and optionally its `.icls` editor scheme, into a ready-to-install plugin. It +has no dependencies beyond Node 22. + +```sh +pnpm theme:import-intellij path/to/MyTheme.theme.json path/to/MyTheme.icls --out ~/my-theme --author "Original Author" +# or: node scripts/theme/import-intellij-theme.ts … +``` + +It maps the keys in the tables below, resolves named colours from the theme's +`colors` section and `*` wildcards, and takes `kind` from `"dark"` (or the +`.icls` `parent_scheme`). Tokens without an IntelliJ counterpart keep the +built-in values for that kind. Open the result next to the template and fill +in what matters to you. Themes that ship only a `.theme.json` with an +`editorScheme` path need that `.icls` passed as the second argument. Themes that +ship no `.icls` at all keep the built-in syntax colours. + +#### `.theme.json` → UI theme + +| OpenIDEA token | IntelliJ `ui` key (first present wins) | +|---|---| +| `--bg-app` | `Panel.background` | +| `--bg-frame` | `MainWindow.background`, `MainToolbar.background` | +| `--bg-island`, `--bg-elev` | `ToolWindow.background` | +| `--bg-popup` | `Popup.background`, `PopupMenu.background` | +| `--bg-hover` | `List.hoverBackground`, `Tree.hoverBackground` | +| `--bg-selected`, `--bg-tree-selected` | `List.selectionBackground`, `Tree.selectionBackground` | +| `--bg-tree-selected-inactive` | `Tree.selectionInactiveBackground` | +| `--bg-input`, `--bg-field` | `TextField.background` | +| `--border` | `Borders.color` | +| `--border-strong`, `--border-window` | `Component.borderColor`, `Borders.ContrastBorderColor` | +| `--fg`, `--fg-emphasis` | `Label.foreground` | +| `--fg-secondary`, `--fg-muted` | `Label.infoForeground`, `Component.infoForeground` | +| `--fg-subtle`, `--fg-faint` | `Label.disabledForeground` | +| `--accent` | `Button.default.startBackground`, `Component.focusColor` | +| `--fg-on-accent`, `--fg-button-primary` | `Button.default.foreground` | +| `--link`, `--link-hover` | `Link.activeForeground`, `Link.hoverForeground` | +| `--danger`, `--warning` | `Component.errorFocusColor`, `Component.warningFocusColor` | +| `--bg-tooltip`, `--fg-tooltip`, `--border-tooltip` | `ToolTip.background`, `.foreground`, `.borderColor` | +| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` | +| `--bg-tab-selected`, `--border-tab-selected` | `EditorTabs.underlinedTabBackground`, `EditorTabs.underlineColor` | +| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` | +| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` | +| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `ScrollBar.Mac.thumbColor`, `ScrollBar.Mac.hoverThumbColor` | +| `--bg-toggle-*` | `ToggleButton.onBackground`, `.offBackground`, `.buttonColor` | +| `--bg-feedback-*`, `--border-feedback-*` | `Banner.infoBackground`, `Banner.errorBorderColor`, … | + +The full table is `UI_KEYS` in [`scripts/theme/intellij.ts`](../../scripts/theme/intellij.ts). + +#### `.icls` → editor scheme + +| OpenIDEA token | `.icls` option | +|---|---| +| `--fg-editor`, `--bg-editor` | attribute `TEXT` (foreground, background) | +| `--caret`, `--bg-current-line` | colour `CARET_COLOR`, `CARET_ROW_COLOR` | +| `--bg-editor-selection` | colour `SELECTION_BACKGROUND` | +| `--fg-line-number`, `--fg-line-number-current` | colour `LINE_NUMBERS_COLOR`, `LINE_NUMBER_ON_CARET_ROW_COLOR` | +| `--border-indent-guide`, `--border-right-margin`, `--fg-whitespace` | colour `INDENT_GUIDE`, `RIGHT_MARGIN_COLOR`, `WHITESPACES` | +| `--vcs-*`, `--gutter-*` | colour `FILESTATUS_*`, `MODIFIED_LINES_COLOR`, `ADDED_LINES_COLOR`, `DELETED_LINES_COLOR` | +| `--syn-keyword`, `--syn-string`, `--syn-number` | `DEFAULT_KEYWORD`, `DEFAULT_STRING`, `DEFAULT_NUMBER` | +| `--syn-comment` (+ `--syn-comment-style`), `--syn-doc-comment` | `DEFAULT_LINE_COMMENT` (+ `FONT_TYPE`), `DEFAULT_DOC_COMMENT` | +| `--syn-function`, `--syn-function-call` | `DEFAULT_FUNCTION_DECLARATION`, `DEFAULT_FUNCTION_CALL` | +| `--syn-type`, `--syn-constant`, `--syn-property` | `DEFAULT_CLASS_NAME`, `DEFAULT_CONSTANT`, `DEFAULT_INSTANCE_FIELD` | +| `--syn-variable`, `--syn-param`, `--syn-builtin` | `DEFAULT_LOCAL_VARIABLE`, `DEFAULT_PARAMETER`, `DEFAULT_PREDEFINED_SYMBOL` | +| `--syn-annotation`, `--syn-decorator` | `DEFAULT_METADATA` | +| `--syn-punct`, `--syn-escape` | `DEFAULT_OPERATION_SIGN`, `DEFAULT_VALID_STRING_ESCAPE` | +| `--syn-tag`, `--syn-attribute`, `--syn-todo`, `--syn-invalid` | `DEFAULT_TAG`, `DEFAULT_ATTRIBUTE`, `TODO_DEFAULT_ATTRIBUTES`, `BAD_CHARACTER` | +| `--wave-error`, `--wave-warning` | `ERRORS_ATTRIBUTES`, `WARNING_ATTRIBUTES` (effect colour) | +| `--bg-matched-brace`, `--bg-identifier-under-caret` | `MATCHED_BRACE_ATTRIBUTES`, `IDENTIFIER_UNDER_CARET_ATTRIBUTES` | +| `--bg-text-search-result`, `--bg-search-match` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` | +| `--syn-self`, `--syn-keyword-arg`, `--syn-docstring`, `--syn-predefined` | `PY.SELF_PARAMETER`, `PY.KEYWORD_ARGUMENT`, `PY.DOC_COMMENT`, `PY.BUILTIN_NAME` | +| `--ansi-*` | `CONSOLE_*_OUTPUT` (`CONSOLE_DARKGRAY_OUTPUT` is bright black, `CONSOLE_GRAY_OUTPUT` white) | + +The full tables are `ICLS_COLORS` and `ICLS_ATTRIBUTES` in the same file. + +### From VS Code (by hand) + +A VS Code theme (`themes/*.json` in the extension) has `type` (`dark` / `light` +/ `hc`), `colors` (workbench) and `tokenColors` (TextMate scopes). Set `kind` +from `type`, then map: + +| OpenIDEA token | VS Code `colors` key | +|---|---| +| `--bg-frame` | `titleBar.activeBackground`, `activityBar.background` | +| `--bg-island`, `--bg-app` | `editor.background`, `sideBar.background` | +| `--bg-elev` | `sideBarSectionHeader.background`, `editorGroupHeader.tabsBackground` | +| `--bg-popup`, `--border-popup` | `editorWidget.background`, `editorWidget.border` | +| `--bg-hover` | `list.hoverBackground` | +| `--bg-selected`, `--bg-tree-selected` | `list.activeSelectionBackground` | +| `--bg-tree-selected-inactive` | `list.inactiveSelectionBackground` | +| `--border` | `panel.border`, `sideBar.border` | +| `--fg` | `foreground`, `sideBar.foreground` | +| `--fg-muted`, `--fg-subtle` | `descriptionForeground`, `disabledForeground` | +| `--accent`, `--fg-on-accent` | `button.background`, `button.foreground` | +| `--focus-ring-soft` | `focusBorder` | +| `--link` | `textLink.foreground` | +| `--bg-input`, `--bg-field` | `input.background` | +| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` | +| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` | +| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` | +| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` | +| `--fg-editor` | `editor.foreground` | +| `--caret` | `editorCursor.foreground` | +| `--bg-current-line` | `editor.lineHighlightBackground` | +| `--bg-editor-selection`, `--bg-editor-selection-inactive` | `editor.selectionBackground`, `editor.inactiveSelectionBackground` | +| `--fg-line-number`, `--fg-line-number-current` | `editorLineNumber.foreground`, `editorLineNumber.activeForeground` | +| `--border-indent-guide`, `--border-indent-guide-active` | `editorIndentGuide.background1`, `editorIndentGuide.activeBackground1` | +| `--fg-whitespace` | `editorWhitespace.foreground` | +| `--bg-text-search-result`, `--bg-text-search-result-current` | `editor.findMatchHighlightBackground`, `editor.findMatchBackground` | +| `--bg-matched-brace` | `editorBracketMatch.background` | +| `--bg-identifier-under-caret`, `-write` | `editor.wordHighlightBackground`, `editor.wordHighlightStrongBackground` | +| `--wave-error`, `--wave-warning` | `editorError.foreground`, `editorWarning.foreground` | +| `--gutter-modified`, `--gutter-added`, `--gutter-deleted` | `editorGutter.modifiedBackground`, `.addedBackground`, `.deletedBackground` | +| `--fg-inlay`, `--bg-inlay` | `editorInlayHint.foreground`, `editorInlayHint.background` | +| `--ansi-*` | `terminal.ansiBlack` … `terminal.ansiBrightWhite` | + +`tokenColors` → syntax tokens, by TextMate scope: + +| OpenIDEA token | Scope | +|---|---| +| `--syn-keyword` | `keyword`, `storage.type`, `storage.modifier` | +| `--syn-string` | `string` | +| `--syn-escape` | `constant.character.escape` | +| `--syn-number` | `constant.numeric` | +| `--syn-constant` | `constant.language`, `variable.other.constant` | +| `--syn-comment`, `--syn-doc-comment` | `comment`, `comment.block.documentation` | +| `--syn-function`, `--syn-function-call` | `entity.name.function`, `meta.function-call` | +| `--syn-type` | `entity.name.type`, `entity.name.class`, `support.class` | +| `--syn-variable`, `--syn-param` | `variable`, `variable.parameter` | +| `--syn-property` | `variable.other.property`, `variable.other.member` | +| `--syn-builtin`, `--syn-predefined` | `support.function`, `support.variable` | +| `--syn-self` | `variable.language.self`, `variable.language.this` | +| `--syn-annotation`, `--syn-decorator` | `meta.decorator`, `storage.type.annotation` | +| `--syn-punct` | `punctuation`, `keyword.operator` | +| `--syn-tag`, `--syn-attribute` | `entity.name.tag`, `entity.other.attribute-name` | +| `--syn-regexp` | `string.regexp` | +| `--syn-markup-heading`, `--syn-markup-code`, `--syn-link` | `markup.heading`, `markup.inline.raw`, `markup.underline.link` | +| `--syn-invalid` | `invalid` | + +A `fontStyle: "italic"` on comments maps to `"--syn-comment-style": "italic"`. + +## Testing a theme + +- Install it from disk (or as a dev plugin), apply it, and walk through: the + Project tool window with a selection, the editor with a few languages, Search + Everywhere, a popup menu, a tooltip, Settings, the terminal (`ls --color`), + a diff, and the VCS colours in the Commit tool window. +- *Settings › Plugins* shows a load error (with the file and line) if the JSON + or a value is invalid. *Reload* re-reads a dev plugin. +- Text should keep at least WCAG AA contrast (4.5:1) on its surfaces. The + built-in themes are checked by `src/features/settings/__tests__/contrast.test.ts`. +- Repository checks (for changes to this repo): `pnpm vitest run scripts/theme` + validates the template against tokens.css and keeps this reference current, + and `cargo test -p ide-plugin` loads the template through the plugin host. + +## Token reference + +Generated from `src/styles/tokens.css` (the variables of its Islands Light +block, which redefines every colour token of Islands Dark). `pnpm gen:theme-docs` +regenerates it. The `scripts/theme` tests fail when it, or the template, misses +a token. + + + +194 themeable tokens: 112 in the UI theme file, 82 in the editor colour scheme file. + +### Surfaces (UI theme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--bg-app` | `#191a1c` | `#ffffff` | +| `--bg-elev` | `#212326` | `#f7f8f9` | +| `--bg-popup` | `#26282c` | `#ffffff` | +| `--bg-hover` | `#2e2f30` | `#ededed` | +| `--bg-selected` | `#2a4371` | `#d0dffe` | +| `--bg-selected-strong` | `#2e4d89` | `#bdd3ff` | +| `--bg-current-line` | `#1f2024` | `#f5f8fe` | +| `--bg-debug-line` | `#2a5091` | `#d4e2ff` | +| `--bg-backdrop` | `#26282c` | `#e9eaee` | +| `--bg-input` | `#191a1c` | `#ffffff` | + +### Lines (UI theme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--border` | `#26282c` | `#e9eaee` | +| `--border-popup` | `#33353b` | `#d1d3d9` | +| `--border-strong` | `#40434a` | `#d1d3d9` | +| `--border-window` | `#40434a` | `#d1d3d9` | +| `--control-off` | `#5f6269` | `#c3c5cb` | + +### Text (UI theme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--fg-strong` | `#dfe1e5` | `#000000` | +| `--fg-emphasis` | `#d1d3d9` | `#000000` | +| `--fg` | `#d1d3d9` | `#000000` | +| `--fg-secondary` | `#9fa2a8` | `#5f6269` | +| `--fg-muted` | `#8c8f95` | `#6c707e` | +| `--fg-subtle` | `#73767c` | `#73767c` | +| `--fg-faint` | `#5f6269` | `#9fa2a8` | +| `--fg-gutter` | `#4b5059` | `#aeb3c2` | +| `--fg-icon` | `#ced0d6` | `#6c707e` | + +### Accent (UI theme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--accent` | `#3871e1` | `#3871e1` | +| `--link` | `#71a1fe` | `#2f5eb9` | +| `--link-hover` | `#92b7ff` | `#2e4d89` | + +### Status (UI theme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--danger` | `#f57e84` | `#c54e58` | +| `--warning` | `#d59637` | `#a56906` | +| `--success` | `#6db083` | `#338555` | +| `--db-connected` | `#2fe02f` | `#00ff00` | +| `--db-connected-ring` | `#1d6b1d` | `#5b995b` | +| `--info` | `#548af7` | `#3871e1` | + +### Vcs (UI theme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--vcs-modified` | `#70aeff` | `#0033b3` | +| `--vcs-added` | `#73bd79` | `#067d17` | +| `--vcs-deleted` | `#6f737a` | `#6c707e` | +| `--vcs-untracked` | `#e88f89` | `#b23247` | +| `--vcs-ignored` | `#d69a6b` | `#8c4f00` | +| `--vcs-conflict` | `#de6a66` | `#de1b2e` | +| `--gutter-modified` | `#375fad` | `#88adf7` | +| `--gutter-added` | `#549159` | `#7fc784` | +| `--gutter-deleted` | `#868a91` | `#767a8a` | + +### Syntax (editor scheme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--syn-keyword` | `#cf8e6d` | `#0033b3` | +| `--syn-string` | `#6aab73` | `#067d17` | +| `--syn-type` | `#bcbec4` | `#000000` | +| `--syn-function` | `#56a8f5` | `#00627a` | +| `--syn-function-call` | `#bcbec4` | `#080808` | +| `--syn-comment` | `#7a7e85` | `#8c8c8c` | +| `--syn-comment-style` | `normal` | `italic` | +| `--syn-doc-comment` | `#5f826b` | `#8c8c8c` | +| `--syn-docstring` | `#5f826b` | `#067d17` | +| `--syn-annotation` | `#b3ae60` | `#9e880d` | +| `--syn-decorator` | `#b3ae60` | `#9e880d` | +| `--syn-number` | `#2aacb8` | `#1750eb` | +| `--syn-punct` | `#bcbec4` | `#080808` | +| `--syn-variable` | `#bcbec4` | `#080808` | +| `--syn-param` | `#bcbec4` | `#080808` | +| `--syn-property` | `#c77dbb` | `#871094` | +| `--syn-constant` | `#c77dbb` | `#871094` | +| `--syn-builtin` | `#8888c6` | `#000080` | +| `--syn-self` | `#94558d` | `#94558d` | +| `--syn-keyword-arg` | `#aa4926` | `#660099` | +| `--syn-predefined` | `#b200b2` | `#b200b2` | +| `--syn-escape` | `#cf8e6d` | `#0037a6` | +| `--syn-regexp` | `#42c3d4` | `#264eff` | +| `--syn-tag` | `#d5b778` | `#0033b3` | +| `--syn-attribute` | `#bcbec4` | `#174ad4` | +| `--syn-todo` | `#8bb33d` | `#008dde` | +| `--syn-link` | `#548af7` | `#006dcc` | +| `--syn-markup-heading` | `#cf8e6d` | `#871094` | +| `--syn-markup-code` | `#6aab73` | `#067d17` | +| `--syn-invalid` | `#f75464` | `#f50000` | +| `--syn-inline-value` | `#868a91` | `#8c8c8c` | + +### Editor (editor scheme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--fg-editor` | `#bcbec4` | `#080808` | +| `--caret` | `#ced0d6` | `#000000` | +| `--fg-line-number` | `#4b5059` | `#aeb3c2` | +| `--fg-line-number-current` | `#a1a3ab` | `#767a8a` | +| `--border-gutter` | `#26282c` | `#ebecf0` | +| `--border-indent-guide` | `#323438` | `#ebecf0` | +| `--border-indent-guide-active` | `#4e5157` | `#aeb3c2` | +| `--border-right-margin` | `#323438` | `#ebecf0` | +| `--bg-editor-selection` | `#214283` | `#a6d2ff` | +| `--bg-editor-selection-inactive` | `#1d3460` | `#c9e3ff` | +| `--bg-text-search-result` | `#114957` | `#fcd47e` | +| `--bg-text-search-result-current` | `#165e70` | `#f2bf57` | +| `--bg-matched-brace` | `#43454a` | `#93d9d9` | +| `--matched-brace-weight` | `700` | `normal` | +| `--bg-matched-tag` | `#3f4045` | `#c9ecec` | +| `--bg-fold-placeholder` | `#393b40` | `#e9f5e6` | +| `--fg-fold-placeholder` | `#868991` | `#414d41` | +| `--fg-fold-marker` | `#6f737a` | `#8b8e94` | +| `--fg-whitespace` | `#6f737a` | `#b5b7bd` | +| `--fg-unused` | `#6f737a` | `#8c8c8c` | +| `--bg-identifier-under-caret` | `#373b39` | `#edebfc` | +| `--bg-identifier-under-caret-write` | `#402f33` | `#fce8f4` | +| `--fg-inlay` | `#868a91` | `#73767c` | +| `--bg-inlay` | `#393b40` | `#00000012` | +| `--wave-error` | `#fa6675` | `#f50000` | +| `--wave-warning` | `#f2c55c` | `#f2bf57` | +| `--editor-error-decoration` | `underline wavy #fa6675` | `underline wavy #f50000` | +| `--editor-warning-decoration` | `underline wavy #f2c55c` | `none` | +| `--editor-warning-background` | `transparent` | `#fcf4d4` | +| `--bg-breakpoint-line` | `#40252b` | `#faeae6` | +| `--fg-completion-match` | `#538af9` | `#2f5eb9` | +| `--bg-editor-inline` | `#212326` | `#f7f8f9` | +| `--bg-editor-banner` | `#233558` | `#f7f8ff` | +| `--fg-weak-warning` | `#6f737a` | `#a8adbd` | +| `--fg-bulb` | `#f2c55c` | `#ffaf0f` | + +### ANSI palette (editor scheme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--ansi-black` | `#000000` | `#000000` | +| `--ansi-red` | `#f0524f` | `#e5372a` | +| `--ansi-green` | `#5c962c` | `#367a29` | +| `--ansi-yellow` | `#a68a0d` | `#ad8d2b` | +| `--ansi-blue` | `#3993d4` | `#3866d1` | +| `--ansi-magenta` | `#a771bf` | `#a33cae` | +| `--ansi-cyan` | `#00a3a3` | `#3c8b8d` | +| `--ansi-white` | `#808080` | `#8b8e94` | +| `--ansi-bright-black` | `#595959` | `#73767c` | +| `--ansi-bright-red` | `#ff4050` | `#f05046` | +| `--ansi-bright-green` | `#4fc414` | `#2e9b3f` | +| `--ansi-bright-yellow` | `#e5bf00` | `#b58e00` | +| `--ansi-bright-blue` | `#1fb0ff` | `#3d82f0` | +| `--ansi-bright-magenta` | `#ed7eed` | `#c04bcf` | +| `--ansi-bright-cyan` | `#00e5e5` | `#1b9ea3` | +| `--ansi-bright-white` | `#ffffff` | `#5f6269` | + +### Shadows and window controls (UI theme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--shadow-popup` | `0 6px 20px rgba(0, 0, 0, 0.45)` | `0 6px 20px rgba(0, 0, 0, 0.12)` | +| `--shadow-dialog` | `0 0 0 0.5px rgba(0, 0, 0, 0.6), 0 10px 24px rgba(0, 0, 0, 0.35), 0 24px 72px rgba(0, 0, 0, 0.45)` | `0 0 0 0.5px rgba(0, 0, 0, 0.1), 0 10px 24px rgba(0, 0, 0, 0.1), 0 24px 72px rgba(0, 0, 0, 0.2)` | +| `--traffic-close` | `#ff5f57` | `#ff5f57` | +| `--traffic-close-border` | `#e0443e` | `#e0443e` | +| `--traffic-close-pressed` | `#bf4942` | `#bf4942` | +| `--traffic-glyph` | `#4d0000` | `#4d0000` | +| `--traffic-off` | `#4b4c4f` | `#dddddd` | +| `--traffic-off-border` | `#404144` | `#c8c8c8` | + +### Additive (UI theme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--bg-tooltip` | `#33353b` | `#ffffff` | +| `--fg-tooltip` | `#d1d3d9` | `#000000` | +| `--border-tooltip` | `#33353b` | `#d1d3d9` | +| `--bg-control` | `#26282c` | `#ffffff` | +| `--bg-control-hover` | `#33353b` | `#f7f8f9` | +| `--bg-active` | `#40434a` | `#dddfe4` | +| `--bg-scrim` | `rgba(0, 0, 0, 0.45)` | `rgba(0, 0, 0, 0.16)` | +| `--fg-on-accent` | `#ffffff` | `#ffffff` | +| `--shadow-tooltip` | `0 4px 12px rgba(0, 0, 0, 0.4)` | `0 4px 12px rgba(0, 0, 0, 0.12)` | +| `--shadow-balloon` | `0 8px 24px rgba(0, 0, 0, 0.45)` | `0 8px 24px rgba(0, 0, 0, 0.14)` | + +### Islands contract (UI theme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--bg-frame` | `#26282c` | `#e9eaee` | +| `--bg-island` | `#191a1c` | `#ffffff` | +| `--bg-stripe-hover` | `#ffffff17` | `#00000012` | +| `--bg-stripe-selected` | `#ffffff29` | `#00000020` | +| `--fg-stripe-selected-active` | `#ffffff` | `#ffffff` | +| `--fg-stripe-label` | `#9fa2a8` | `#5f6269` | +| `--bg-tab-selected` | `#233558` | `#e3ebfe` | +| `--border-tab-selected` | `#2e4d89` | `#a7c5ff` | +| `--bg-tab-selected-inactive` | `#26282c` | `#e9eaee` | +| `--border-tab-selected-inactive` | `#40434a` | `#d1d3d9` | +| `--bg-tab-hover` | `#ffffff17` | `#00000012` | +| `--border-editor-tabs` | `#26282c` | `#e9eaee` | +| `--bg-tree-selected` | `#2a4371` | `#d0dffe` | +| `--bg-tree-selected-inactive` | `#33353b` | `#e9eaee` | +| `--bg-tree-hover` | `#ffffff10` | `#00000008` | +| `--bg-toolbar-hover` | `#ffffff17` | `#00000012` | +| `--bg-toolbar-pressed` | `#ffffff29` | `#00000020` | +| `--bg-toolbar-selected` | `#2e4d89` | `#d0dffe` | +| `--bg-toolbar-selected-hover` | `#2f5eb9` | `#bdd3ff` | +| `--bg-button-primary-hover` | `#2f5eb9` | `#2f5eb9` | +| `--fg-button-primary` | `#ffffff` | `#ffffff` | +| `--bg-button` | `transparent` | `#ffffff` | +| `--border-button` | `#40434a` | `#d1d3d9` | +| `--bg-field` | `#191a1c` | `#ffffff` | +| `--bg-checkbox` | `#191a1c` | `#ffffff` | +| `--border-checkbox` | `#5f6269` | `#a8adbd` | +| `--bg-toggle-on` | `#338555` | `#4e9d6c` | +| `--fg-toggle-on` | `#ffffff` | `#ffffff` | +| `--bg-toggle-off` | `#191a1c` | `#f7f8f9` | +| `--fg-toggle-off` | `#73767c` | `#73767c` | +| `--border-toggle` | `#5f6269` | `#c3c5cb` | +| `--bg-toggle-knob` | `#5f6269` | `#c3c5cb` | +| `--focus-ring-soft` | `#3d6185` | `#97c3f3` | +| `--scrollbar-thumb` | `#ffffff33` | `#00000033` | +| `--scrollbar-thumb-hover` | `#ffffff59` | `#00000080` | +| `--bg-search-match` | `#ba9752` | `#fee6b1` | +| `--bg-search-match-current` | `#f2c55c` | `#fcd47e` | +| `--fg-search-match` | `#191a1c` | `#000000` | +| `--bg-dialog` | `#191a1c` | `#f7f8f9` | +| `--bg-popup-inline` | `#26282c` | `#f7f8f9` | +| `--bg-popup-advertiser` | `#26282c` | `#f7f8f9` | +| `--bg-feedback-info` | `#233558` | `#f7f8ff` | +| `--border-feedback-info` | `#2e4d89` | `#bdd3ff` | +| `--bg-feedback-error` | `#56272b` | `#fff6f5` | +| `--border-feedback-error` | `#80383e` | `#ffc4c5` | +| `--bg-feedback-warning` | `#44321d` | `#fff6e9` | +| `--border-feedback-warning` | `#694820` | `#f4cd9a` | +| `--bg-feedback-success` | `#203b2a` | `#f5faf3` | +| `--border-feedback-success` | `#29583c` | `#bbdbc2` | +| `--bg-counter` | `#73767c` | `#8b8e94` | +| `--fg-counter` | `#000000` | `#ffffff` | +| `--bg-progress-track` | `#40434a` | `#dddfe4` | + + diff --git a/package.json b/package.json index a2cbd314..3cc6f343 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,10 @@ "test:watch": "vitest", "lint": "eslint .", "format": "prettier --write .", - "typecheck": "tsc --noEmit && tsc -p tsconfig.node.json --noEmit && tsc -p perf --noEmit", + "typecheck": "tsc --noEmit && tsc -p tsconfig.node.json --noEmit && tsc -p perf --noEmit && tsc -p scripts --noEmit", "gen:bindings": "cargo test -p openidea --test export_bindings", + "gen:theme-docs": "node scripts/theme/gen-docs.ts", + "theme:import-intellij": "node scripts/theme/import-intellij-theme.ts", "dev:app": "tauri dev --config src-tauri/tauri.dev.conf.json", "e2e": "playwright test", "perf": "node perf/run.ts", diff --git a/plugins/README.md b/plugins/README.md index d09cb246..101f802a 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -179,8 +179,14 @@ never affected. Tokens are the CSS variables of `src/styles/tokens.css` (`--bg-*`, `--fg-*`, `--border*`, `--accent`, `--syn-*`, `--vcs-*`, `--gutter-*`, …). Unlisted tokens -keep the built-in values. Values must be plain CSS values (no `url()`, `;`, -braces). Pick a theme in Settings › Plugins › *Apply*. +keep the values of the built-in base named by `kind`. Values must be plain CSS +values (no `url()`, `;`, braces); `//` and `/* */` comments are allowed. A +manifest theme entry may add `editor-scheme = "themes/x.scheme.json"` (editor +colours, merged over the theme). Pick a theme in Settings › Plugins › *Apply*. + +Start from [`templates/theme-plugin`](../templates/theme-plugin); the full guide, +token reference and IntelliJ / VS Code porting tables are in +[`docs/plugins/themes.md`](../docs/plugins/themes.md). ## 4. Languages diff --git a/scripts/theme/gen-docs.ts b/scripts/theme/gen-docs.ts new file mode 100644 index 00000000..46269309 --- /dev/null +++ b/scripts/theme/gen-docs.ts @@ -0,0 +1,14 @@ +/** + * Regenerates the token reference in docs/plugins/themes.md from + * src/styles/tokens.css (`pnpm gen:theme-docs`). scripts/theme/theme.test.ts + * fails when the checked-in reference is stale. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { referenceMarkdown, themeTokenGroups, withReference } from "./tokens.ts"; + +const DOC = "docs/plugins/themes.md"; +const groups = themeTokenGroups(readFileSync("src/styles/tokens.css", "utf8")); +const doc = readFileSync(DOC, "utf8"); +const next = withReference(doc, referenceMarkdown(groups)); +if (next !== doc) writeFileSync(DOC, next); +console.log(next === doc ? `${DOC} is up to date` : `updated ${DOC}`); diff --git a/scripts/theme/import-intellij-theme.ts b/scripts/theme/import-intellij-theme.ts new file mode 100644 index 00000000..4fdd0cf1 --- /dev/null +++ b/scripts/theme/import-intellij-theme.ts @@ -0,0 +1,52 @@ +/** + * Converts an IntelliJ theme into an OpenIDEA theme plugin package. + * + * pnpm theme:import-intellij [file.icls] --out [--id ] [--author ] + * + * Writes /plugin.toml and /themes/.theme.json (+ .scheme.json), + * ready for Settings › Plugins › Install Plugin from Disk…. Only port themes + * whose licence allows it, and credit the original author. + */ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; +import { parseArgs } from "node:util"; +import { convertIntellijTheme, pluginManifest } from "./intellij.ts"; + +const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { out: { type: "string" }, id: { type: "string" }, author: { type: "string" } }, +}); +const [themePath, iclsPath] = positionals; +if (!themePath || !values.out) { + console.error( + "usage: pnpm theme:import-intellij [file.icls] --out [--id ] [--author ]", + ); + process.exit(2); +} + +const converted = convertIntellijTheme( + readFileSync(themePath, "utf8"), + iclsPath ? readFileSync(iclsPath, "utf8") : undefined, + values.id, +); +const { theme, scheme } = converted; +const source = [basename(themePath), iclsPath && basename(iclsPath)].filter(Boolean).join(" + "); +const header = (what: string) => `// ${theme.name}: ${what}, converted from ${source}.\n`; + +mkdirSync(join(values.out, "themes"), { recursive: true }); +writeFileSync(join(values.out, "plugin.toml"), pluginManifest(converted, values.author ?? "Unknown", source)); +writeFileSync( + join(values.out, "themes", `${theme.id}.theme.json`), + header("UI theme") + JSON.stringify(theme, null, 2) + "\n", +); +if (scheme) { + writeFileSync( + join(values.out, "themes", `${theme.id}.scheme.json`), + header("editor colour scheme") + JSON.stringify(scheme, null, 2) + "\n", + ); +} +console.log( + `${theme.name} (${theme.kind}): ${Object.keys(theme.tokens).length} UI tokens` + + (scheme ? `, ${Object.keys(scheme.tokens).length} editor tokens` : "") + + ` → ${values.out}\nUnmapped tokens keep the built-in ${theme.kind} values; see docs/plugins/themes.md to fill them in.`, +); diff --git a/scripts/theme/intellij.ts b/scripts/theme/intellij.ts new file mode 100644 index 00000000..c9b35e16 --- /dev/null +++ b/scripts/theme/intellij.ts @@ -0,0 +1,318 @@ +/** + * Converts an IntelliJ theme (`.theme.json`, optionally with its editor scheme + * `.icls`) into an OpenIDEA theme + editor scheme. The mapping covers the + * main keys only; anything unmapped keeps the built-in value of the base + * (`kind`). Pure functions, no dependencies: see import-intellij-theme.ts + * for the command line. + */ +import { stripJsonComments } from "./tokens.ts"; + +/** OpenIDEA token ← IntelliJ `ui` keys, first present wins. `*.prop` wildcards apply too. */ +export const UI_KEYS: Record = { + "--bg-app": ["Panel.background"], + "--bg-frame": ["MainWindow.background", "MainToolbar.background", "Panel.background"], + "--bg-island": ["ToolWindow.background", "Panel.background"], + "--bg-elev": ["ToolWindow.background", "SidePanel.background"], + "--bg-popup": ["Popup.background", "PopupMenu.background"], + "--bg-backdrop": ["Popup.background"], + "--bg-hover": ["List.hoverBackground", "Tree.hoverBackground"], + "--bg-selected": ["List.selectionBackground", "Tree.selectionBackground"], + "--bg-selected-strong": ["List.selectionBackground"], + "--bg-tree-selected": ["Tree.selectionBackground", "List.selectionBackground"], + "--bg-tree-selected-inactive": ["Tree.selectionInactiveBackground", "List.selectionInactiveBackground"], + "--bg-input": ["TextField.background"], + "--bg-field": ["TextField.background"], + "--bg-dialog": ["Dialog.background", "Panel.background"], + "--border": ["Borders.color"], + "--border-popup": ["Popup.borderColor", "Popup.Border.color"], + "--border-strong": ["Component.borderColor", "Borders.ContrastBorderColor"], + "--border-window": ["Borders.ContrastBorderColor", "Component.borderColor"], + "--fg": ["Label.foreground"], + "--fg-emphasis": ["Label.foreground"], + "--fg-strong": ["Label.selectedForeground", "Label.foreground"], + "--fg-secondary": ["Label.infoForeground", "Component.infoForeground"], + "--fg-muted": ["Label.infoForeground", "Component.infoForeground"], + "--fg-subtle": ["Label.disabledForeground"], + "--fg-faint": ["Label.disabledForeground"], + "--accent": ["Button.default.startBackground", "Button.default.background", "Component.focusColor"], + "--fg-on-accent": ["Button.default.foreground"], + "--fg-button-primary": ["Button.default.foreground"], + "--focus-ring-soft": ["Component.focusColor"], + "--link": ["Link.activeForeground", "Hyperlink.linkColor"], + "--link-hover": ["Link.hoverForeground", "Link.activeForeground"], + "--danger": ["Component.errorFocusColor"], + "--warning": ["Component.warningFocusColor"], + "--bg-tooltip": ["ToolTip.background"], + "--fg-tooltip": ["ToolTip.foreground"], + "--border-tooltip": ["ToolTip.borderColor"], + "--bg-button": ["Button.startBackground", "Button.background"], + "--border-button": ["Button.startBorderColor", "Button.borderColor"], + "--bg-checkbox": ["Checkbox.Background.Default"], + "--border-checkbox": ["Checkbox.Border.Default"], + "--bg-tab-selected": ["EditorTabs.underlinedTabBackground", "EditorTabs.selectedBackground"], + "--border-tab-selected": ["EditorTabs.underlineColor"], + "--bg-tab-hover": ["EditorTabs.hoverBackground"], + "--border-editor-tabs": ["EditorTabs.borderColor"], + "--bg-toolbar-hover": ["ActionButton.hoverBackground"], + "--bg-toolbar-pressed": ["ActionButton.pressedBackground"], + "--bg-stripe-hover": ["ToolWindow.Button.hoverBackground"], + "--bg-stripe-selected": ["ToolWindow.Button.selectedBackground"], + "--scrollbar-thumb": ["ScrollBar.Mac.thumbColor", "ScrollBar.thumbColor"], + "--scrollbar-thumb-hover": ["ScrollBar.Mac.hoverThumbColor", "ScrollBar.hoverThumbColor"], + "--bg-progress-track": ["ProgressBar.trackColor"], + "--bg-counter": ["Counter.background"], + "--fg-counter": ["Counter.foreground"], + "--bg-search-match": ["SearchMatch.startBackground", "SearchMatch.endBackground"], + "--bg-toggle-on": ["ToggleButton.onBackground"], + "--fg-toggle-on": ["ToggleButton.onForeground"], + "--bg-toggle-off": ["ToggleButton.offBackground"], + "--fg-toggle-off": ["ToggleButton.offForeground"], + "--border-toggle": ["ToggleButton.borderColor"], + "--bg-toggle-knob": ["ToggleButton.buttonColor"], + "--bg-feedback-info": ["Banner.infoBackground"], + "--border-feedback-info": ["Banner.infoBorderColor"], + "--bg-feedback-error": ["Banner.errorBackground"], + "--border-feedback-error": ["Banner.errorBorderColor"], + "--bg-feedback-warning": ["Banner.warningBackground"], + "--border-feedback-warning": ["Banner.warningBorderColor"], + "--bg-feedback-success": ["Banner.successBackground"], + "--border-feedback-success": ["Banner.successBorderColor"], +}; + +/** `.icls` `` options → tokens. */ +export const ICLS_COLORS: Record = { + CARET_COLOR: ["--caret"], + CARET_ROW_COLOR: ["--bg-current-line"], + SELECTION_BACKGROUND: ["--bg-editor-selection"], + LINE_NUMBERS_COLOR: ["--fg-line-number"], + LINE_NUMBER_ON_CARET_ROW_COLOR: ["--fg-line-number-current"], + GUTTER_BACKGROUND: ["--bg-editor-gutter"], + INDENT_GUIDE: ["--border-indent-guide"], + SELECTED_INDENT_GUIDE: ["--border-indent-guide-active"], + RIGHT_MARGIN_COLOR: ["--border-right-margin"], + WHITESPACES: ["--fg-whitespace"], + METHOD_SEPARATORS_COLOR: ["--border-method-separator"], + FILESTATUS_MODIFIED: ["--vcs-modified"], + FILESTATUS_ADDED: ["--vcs-added"], + FILESTATUS_DELETED: ["--vcs-deleted"], + FILESTATUS_UNKNOWN: ["--vcs-untracked"], + FILESTATUS_IDEA_FILESTATUS_IGNORED: ["--vcs-ignored"], + FILESTATUS_IDEA_FILESTATUS_MERGED_WITH_CONFLICTS: ["--vcs-conflict"], + MODIFIED_LINES_COLOR: ["--gutter-modified"], + ADDED_LINES_COLOR: ["--gutter-added"], + DELETED_LINES_COLOR: ["--gutter-deleted"], +}; + +type Part = "FOREGROUND" | "BACKGROUND" | "EFFECT_COLOR"; + +/** `.icls` `` options → [part, token] pairs. */ +export const ICLS_ATTRIBUTES: Record> = { + TEXT: [ + ["FOREGROUND", "--fg-editor"], + ["BACKGROUND", "--bg-editor"], + ], + DEFAULT_KEYWORD: [["FOREGROUND", "--syn-keyword"]], + DEFAULT_STRING: [["FOREGROUND", "--syn-string"]], + DEFAULT_NUMBER: [["FOREGROUND", "--syn-number"]], + DEFAULT_LINE_COMMENT: [["FOREGROUND", "--syn-comment"]], + DEFAULT_DOC_COMMENT: [["FOREGROUND", "--syn-doc-comment"]], + DEFAULT_FUNCTION_DECLARATION: [["FOREGROUND", "--syn-function"]], + DEFAULT_FUNCTION_CALL: [["FOREGROUND", "--syn-function-call"]], + DEFAULT_CLASS_NAME: [["FOREGROUND", "--syn-type"]], + DEFAULT_CONSTANT: [["FOREGROUND", "--syn-constant"]], + DEFAULT_INSTANCE_FIELD: [["FOREGROUND", "--syn-property"]], + DEFAULT_IDENTIFIER: [["FOREGROUND", "--syn-variable"]], + DEFAULT_LOCAL_VARIABLE: [["FOREGROUND", "--syn-variable"]], + DEFAULT_PARAMETER: [["FOREGROUND", "--syn-param"]], + DEFAULT_PREDEFINED_SYMBOL: [["FOREGROUND", "--syn-builtin"]], + DEFAULT_METADATA: [ + ["FOREGROUND", "--syn-annotation"], + ["FOREGROUND", "--syn-decorator"], + ], + DEFAULT_OPERATION_SIGN: [["FOREGROUND", "--syn-punct"]], + DEFAULT_VALID_STRING_ESCAPE: [["FOREGROUND", "--syn-escape"]], + DEFAULT_TAG: [["FOREGROUND", "--syn-tag"]], + DEFAULT_ATTRIBUTE: [["FOREGROUND", "--syn-attribute"]], + TODO_DEFAULT_ATTRIBUTES: [["FOREGROUND", "--syn-todo"]], + BAD_CHARACTER: [["FOREGROUND", "--syn-invalid"]], + ERRORS_ATTRIBUTES: [["EFFECT_COLOR", "--wave-error"]], + WARNING_ATTRIBUTES: [["EFFECT_COLOR", "--wave-warning"]], + NOT_USED_ELEMENT_ATTRIBUTES: [["FOREGROUND", "--fg-unused"]], + MATCHED_BRACE_ATTRIBUTES: [["BACKGROUND", "--bg-matched-brace"]], + SEARCH_RESULT_ATTRIBUTES: [["BACKGROUND", "--bg-search-match"]], + TEXT_SEARCH_RESULT_ATTRIBUTES: [["BACKGROUND", "--bg-text-search-result"]], + IDENTIFIER_UNDER_CARET_ATTRIBUTES: [["BACKGROUND", "--bg-identifier-under-caret"]], + WRITE_IDENTIFIER_UNDER_CARET_ATTRIBUTES: [["BACKGROUND", "--bg-identifier-under-caret-write"]], + FOLDED_TEXT_ATTRIBUTES: [ + ["FOREGROUND", "--fg-fold-placeholder"], + ["BACKGROUND", "--bg-fold-placeholder"], + ], + INLINE_PARAMETER_HINT: [ + ["FOREGROUND", "--fg-inlay"], + ["BACKGROUND", "--bg-inlay"], + ], + BREAKPOINT_ATTRIBUTES: [["BACKGROUND", "--bg-breakpoint-line"]], + DEBUGGER_INLINED_VALUES: [["FOREGROUND", "--syn-inline-value"]], + "PY.SELF_PARAMETER": [["FOREGROUND", "--syn-self"]], + "PY.KEYWORD_ARGUMENT": [["FOREGROUND", "--syn-keyword-arg"]], + "PY.DOC_COMMENT": [["FOREGROUND", "--syn-docstring"]], + "PY.BUILTIN_NAME": [["FOREGROUND", "--syn-predefined"]], + MARKDOWN_HEADER_LEVEL_1: [["FOREGROUND", "--syn-markup-heading"]], + MARKDOWN_CODE_SPAN: [["FOREGROUND", "--syn-markup-code"]], + MARKDOWN_LINK_TEXT: [["FOREGROUND", "--syn-link"]], + CONSOLE_BLACK_OUTPUT: [["FOREGROUND", "--ansi-black"]], + CONSOLE_RED_OUTPUT: [["FOREGROUND", "--ansi-red"]], + CONSOLE_GREEN_OUTPUT: [["FOREGROUND", "--ansi-green"]], + CONSOLE_YELLOW_OUTPUT: [["FOREGROUND", "--ansi-yellow"]], + CONSOLE_BLUE_OUTPUT: [["FOREGROUND", "--ansi-blue"]], + CONSOLE_MAGENTA_OUTPUT: [["FOREGROUND", "--ansi-magenta"]], + CONSOLE_CYAN_OUTPUT: [["FOREGROUND", "--ansi-cyan"]], + CONSOLE_GRAY_OUTPUT: [["FOREGROUND", "--ansi-white"]], + CONSOLE_DARKGRAY_OUTPUT: [["FOREGROUND", "--ansi-bright-black"]], + CONSOLE_RED_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-red"]], + CONSOLE_GREEN_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-green"]], + CONSOLE_YELLOW_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-yellow"]], + CONSOLE_BLUE_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-blue"]], + CONSOLE_MAGENTA_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-magenta"]], + CONSOLE_CYAN_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-cyan"]], + CONSOLE_WHITE_OUTPUT: [["FOREGROUND", "--ansi-bright-white"]], +}; + +export interface ConvertedTheme { + theme: { id: string; name: string; kind: "dark" | "light"; tokens: Record }; + scheme: { name: string; tokens: Record } | null; +} + +type Json = string | number | boolean | null | Json[] | { [k: string]: Json }; + +/** `"Button": {"default": {"startBackground": x}}` → `Button.default.startBackground`; `"*": {...}` → `*.prop`. */ +function flatten(obj: { [k: string]: Json }, prefix = "", out = new Map()): Map { + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (typeof v === "string") out.set(key, v); + else if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out); + } + return out; +} + +const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i; + +/** IntelliJ colour (`#rrggbb`, `#rrggbbaa`, or a named colour from `colors`) → CSS hex. */ +function resolveColor(value: string, named: Map, depth = 0): string | null { + const m = HEX.exec(value.trim()); + if (m) return `#${m[1]!.toLowerCase()}`; + const ref = named.get(value); + return ref !== undefined && depth < 8 ? resolveColor(ref, named, depth + 1) : null; +} + +/** `.icls` hex: RGB without `#`, leading zeros dropped (`"0"` is black, `"ff"` is #0000ff). */ +function iclsColor(value: string): string | null { + const v = value.trim().toLowerCase(); + if (!/^[0-9a-f]{1,8}$/.test(v)) return null; + return `#${v.length <= 6 ? v.padStart(6, "0") : v.padStart(8, "0")}`; +} + +const attr = (tag: string, name: string) => new RegExp(`${name}="([^"]*)"`).exec(tag)?.[1]; + +/** Parses the colours and attributes of an `.icls` file into tokens. */ +export function convertIcls(xml: string): { name: string; dark: boolean | null; tokens: Record } { + const tokens: Record = {}; + const set = (names: string[], color: string | null) => { + if (color) for (const n of names) tokens[n] ??= color; + }; + const schemeTag = /]*>/.exec(xml)?.[0] ?? ""; + const name = attr(schemeTag, "name") ?? "Imported"; + const parent = attr(schemeTag, "parent_scheme") ?? ""; + const dark = parent ? /darcula|dark/i.test(parent) : null; + + const colors = /([\s\S]*?)<\/colors>/.exec(xml)?.[1] ?? ""; + for (const m of colors.matchAll(/]*\/>/g)) { + const key = attr(m[0], "name"); + const value = attr(m[0], "value"); + if (key && value !== undefined && ICLS_COLORS[key]) set(ICLS_COLORS[key], iclsColor(value)); + } + + const attributes = /([\s\S]*?)<\/attributes>/.exec(xml)?.[1] ?? ""; + for (const m of attributes.matchAll(/