feat(themes): Solarized and One theme plugins; plugin themes from the first frame

Add two contribution-only theme plugins, each with a light and a dark theme
that sets every UI token plus an editor colour scheme with syntax, editor
and terminal ANSI colours:

- plugins/theme-solarized: Solarized Light, Solarized Dark
- plugins/theme-one: One Light, One Dark (Atom One, original HSL values)

They replace the partial theme-solarized-dark sample. Each package ships
the MIT notice of its palette in LICENSE, which is now copied on install
with the files the manifest references. scripts/theme checks every theme
plugin in plugins/ against the token list of tokens.css.

Theme handling that any theme benefits from:

- Start-up: boot.js restores the selected plugin theme's stylesheet and the
  last resolved frame, text and accent colours before any CSS loads, so the
  splash and workbench no longer show the built-in theme until the plugin
  host answers. The UI keeps the boot theme until the first plugin list,
  and the list now waits for the initial plugin load.
- Native windows: the UI reports the plugin theme's resolved frame colour,
  which the plugin host persists and new project windows open in.
- hsl(), color-mix() and var() tokens are resolved to RGB for Tauri's window
  background, the native Settings window (hex) and Mermaid diagrams.
- Mermaid, the terminal and the editor colour scheme follow switches between
  plugin themes of the same light / dark base; an explicit editor scheme
  now replaces a plugin theme's editor colours.

Supersedes #85.

Co-authored-by: bghtyu <3931591+bghtyu@users.noreply.github.com>
Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
This commit is contained in:
l0ng-ai
2026-10-27 17:50:44 +08:00
co-authored by bghtyu
parent 866af3d9cb
commit b424cb32a8
43 changed files with 2035 additions and 180 deletions
+11
View File
@@ -100,6 +100,17 @@ c → cpp, php_only), node names adapted to the bundled grammar versions
definitions re-kinded as `field` / `method`, and missing scopes, definitions and
references added; each file's header notes its changes.
## Theme plugin palettes
The sample theme plugins port MIT-licensed palettes; each package's `LICENSE`
holds the full notice and source revisions:
- `plugins/theme-solarized/`: [Solarized](https://github.com/altercation/solarized),
Copyright (c) 2011 Ethan Schoonover.
- `plugins/theme-one/`: Atom [One Light](https://github.com/atom/one-light-syntax) /
[One Dark](https://github.com/atom/one-dark-syntax) syntax themes, Copyright (c) 2016
GitHub Inc., and their UI themes, Copyright (c) 2014 GitHub Inc.
---
Apache License
+69 -2
View File
@@ -29,14 +29,18 @@ pub use types::*;
use ide_core::{IdeError, IdeResult};
use ide_language::LanguageConfig;
use parking_lot::{Mutex, RwLock};
use parking_lot::{Condvar, Mutex, RwLock};
use runtime::{CallEffects, CallError, PluginEngine, Registrations, Runtime, RuntimeSetup};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
const STATE_FILE: &str = "state.json";
/// Top-level files copied with a package on install besides those the manifest references.
const LICENSE_FILES: &[&str] =
&["LICENSE", "LICENSE.md", "LICENSE.txt", "NOTICE", "NOTICE.md", "NOTICE.txt", "COPYING"];
/// Dev plugins live here, relative to a project root.
pub const DEV_PLUGINS_DIR: &str = ".openidea/plugins";
@@ -52,6 +56,10 @@ struct PersistedState {
struct ThemeRef {
plugin: String,
theme: String,
/// The theme's window frame colour as the UI resolved it (`#rrggbb[aa]`), so
/// new windows open in it before the plugin is loaded.
#[serde(default, skip_serializing_if = "Option::is_none")]
frame: Option<String>,
}
struct Meta {
@@ -95,6 +103,8 @@ pub struct PluginHost {
active_files: Arc<Mutex<HashMap<String, String>>>,
/// Serializes loading / unloading so concurrent windows can't race.
lifecycle: Mutex<()>,
/// Set once the first [`load_all`](Self::load_all) has finished.
initial_load: (Mutex<bool>, Condvar),
}
impl PluginHost {
@@ -114,6 +124,7 @@ impl PluginHost {
state: Mutex::new(state),
active_files: Arc::default(),
lifecycle: Mutex::new(()),
initial_load: (Mutex::new(false), Condvar::new()),
})
}
@@ -129,9 +140,24 @@ impl PluginHost {
self.load_dir(&dir, PluginSource::Installed, None);
}
}
let (done, cv) = &self.initial_load;
*done.lock() = true;
cv.notify_all();
self.emit(None, PluginEvent::CommandsChanged);
}
/// Waits (at most `timeout`) for the first [`load_all`](Self::load_all), so a
/// listing made at startup already holds the installed plugins and the
/// selected theme. Returns whether loading has finished.
pub fn wait_initial_load(&self, timeout: Duration) -> bool {
let (done, cv) = &self.initial_load;
let mut done = done.lock();
if !*done {
cv.wait_while_for(&mut done, |d| !*d, timeout);
}
*done
}
/// Loads new dev plugins from `<root>/.openidea/plugins/` and unloads vanished ones.
pub fn sync_project(&self, root: &Path) {
let _guard = self.lifecycle.lock();
@@ -251,6 +277,11 @@ impl PluginHost {
}
std::fs::copy(src.join(file), to)?;
}
for name in LICENSE_FILES {
if src.join(name).is_file() {
std::fs::copy(src.join(name), staging.join(name))?;
}
}
if target.exists() {
std::fs::remove_dir_all(&target)?;
}
@@ -345,13 +376,45 @@ impl PluginHost {
}
}
self.state.lock().theme =
selection.map(|(plugin, theme)| ThemeRef { plugin: plugin.into(), theme: theme.into() });
selection.map(|(plugin, theme)| ThemeRef { plugin: plugin.into(), theme: theme.into(), frame: None });
self.save_state();
let active = self.active_theme();
self.emit(None, PluginEvent::ThemeChanged { theme: active.clone() });
Ok(active)
}
/// Records the selected theme's resolved window frame colour (`#rgb`,
/// `#rrggbb` or `#rrggbbaa`); ignored without a selected theme.
pub fn set_theme_frame(&self, frame: Option<&str>) -> IdeResult<()> {
if let Some(f) = frame
&& !is_hex_color(f)
{
return Err(IdeError::invalid(format!("not a hex colour: {f:?}")));
}
let changed = match self.state.lock().theme.as_mut() {
Some(t) if t.frame.as_deref() != frame => {
t.frame = frame.map(str::to_owned);
true
}
_ => false,
};
if changed {
self.save_state();
}
Ok(())
}
/// Frame colour for a new window: the recorded one of the selected theme,
/// unless its plugin is disabled. Available before plugins are loaded.
pub fn startup_frame(&self) -> Option<String> {
let state = self.state.lock();
let t = state.theme.as_ref()?;
if state.disabled.contains(&t.plugin) {
return None;
}
t.frame.clone()
}
/// The selected plugin theme, when its plugin is enabled and running.
pub fn active_theme(&self) -> Option<ActiveTheme> {
let sel = self.state.lock().theme.clone()?;
@@ -568,6 +631,10 @@ impl Drop for PluginHost {
}
}
fn is_hex_color(s: &str) -> bool {
s.strip_prefix('#').is_some_and(|h| matches!(h.len(), 3 | 6 | 8) && h.bytes().all(|b| b.is_ascii_hexdigit()))
}
/// Plugin packages found in `dirs` (first occurrence of an id wins). Invalid
/// manifests are skipped; packages with missing files carry a `problem`.
pub fn scan_catalog(dirs: &[PathBuf]) -> Vec<CatalogPlugin> {
+59 -9
View File
@@ -217,10 +217,11 @@ fn runtime_language_and_theme_registration() {
#[test]
fn contribution_only_plugins() {
let f = fixture(Limits::default());
let theme = f.host.install_from_dir(&sample("theme-solarized-dark")).unwrap();
let theme = f.host.install_from_dir(&sample("theme-solarized")).unwrap();
assert_eq!(theme.status, PluginStatus::Static);
assert!(!theme.has_code);
assert_eq!(theme.themes[0].id, "solarized-dark");
let ids: Vec<&str> = theme.themes.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids, ["solarized-light", "solarized-dark"]);
let lang = f.host.install_from_dir(&sample("language-toy")).unwrap();
assert_eq!(lang.status, PluginStatus::Static);
assert_eq!(lang.languages[0].id, "toy");
@@ -228,13 +229,14 @@ fn contribution_only_plugins() {
assert!(f.run("language-toy", "x", None).unwrap_err().contains("no code"));
f.take_events();
let active = f.host.set_theme(Some(("theme-solarized-dark", "solarized-dark"))).unwrap().unwrap();
let active = f.host.set_theme(Some(("theme-solarized", "solarized-dark"))).unwrap().unwrap();
assert_eq!(active.theme.tokens["--bg-app"], "#002b36");
assert_eq!(active.theme.tokens["--syn-string"], "#2aa198");
assert!(f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::ThemeChanged { theme: Some(_) })));
assert_eq!(f.host.list(None).active_theme, Some(active));
// Disabling the plugin withdraws its theme and language.
f.host.set_enabled("theme-solarized-dark", false).unwrap();
f.host.set_enabled("theme-solarized", false).unwrap();
assert!(f.host.active_theme().is_none());
assert!(f.take_events().iter().any(|(_, e)| *e == PluginEvent::ThemeChanged { theme: None }));
f.host.set_enabled("language-toy", false).unwrap();
@@ -260,6 +262,49 @@ fn theme_template_installs_with_its_editor_scheme() {
assert!(tokens.len() > 150);
}
#[test]
fn bundled_theme_plugins_install_with_their_licence() {
let f = fixture(Limits::default());
for (pkg, themes) in
[("theme-solarized", ["solarized-light", "solarized-dark"]), ("theme-one", ["one-light", "one-dark"])]
{
let info = f.host.install_from_dir(&sample(pkg)).unwrap();
assert_eq!(info.status, PluginStatus::Static, "{pkg}: {:?}", info.error);
assert!(info.authors.iter().any(|a| a.starts_with("bghtyu")));
let license = std::fs::read_to_string(f.plugins_dir.join(pkg).join("LICENSE")).unwrap();
assert!(license.contains("MIT License"));
for (theme, kind) in themes.iter().zip([ThemeKind::Light, ThemeKind::Dark]) {
let active = f.host.set_theme(Some((pkg, theme))).unwrap().unwrap();
assert_eq!(active.theme.kind, kind);
let tokens = &active.theme.tokens;
assert!(["--bg-frame", "--syn-keyword", "--ansi-red"].iter().all(|t| tokens.contains_key(*t)));
}
}
}
#[test]
fn theme_frame_follows_the_selection() {
let f = fixture(Limits::default());
f.host.install_from_dir(&sample("theme-one")).unwrap();
// Nothing to attach it to without a selected theme.
f.host.set_theme_frame(Some("#21252b")).unwrap();
assert_eq!(f.host.startup_frame(), None);
f.host.set_theme(Some(("theme-one", "one-dark"))).unwrap();
assert!(f.host.set_theme_frame(Some("hsl(220, 13%, 15%)")).is_err());
f.host.set_theme_frame(Some("#21252b")).unwrap();
assert_eq!(f.host.startup_frame().as_deref(), Some("#21252b"));
f.host.set_enabled("theme-one", false).unwrap();
assert_eq!(f.host.startup_frame(), None);
f.host.set_enabled("theme-one", true).unwrap();
assert_eq!(f.host.startup_frame().as_deref(), Some("#21252b"));
// A new selection drops the old theme's colour until the UI reports the new one.
f.host.set_theme(Some(("theme-one", "one-light"))).unwrap();
assert_eq!(f.host.startup_frame(), None);
f.host.set_theme_frame(Some("#eaeaeb")).unwrap();
f.host.set_theme(None).unwrap();
assert_eq!(f.host.startup_frame(), None);
}
#[test]
fn theme_with_broken_editor_scheme_fails_to_load() {
let f = fixture(Limits::default());
@@ -282,12 +327,17 @@ fn theme_with_broken_editor_scheme_fails_to_load() {
fn state_persists_across_restarts() {
let f = fixture(Limits::default());
f.host.install_from_dir(&sample("hello")).unwrap();
f.host.install_from_dir(&sample("theme-solarized-dark")).unwrap();
f.host.install_from_dir(&sample("theme-solarized")).unwrap();
f.host.set_enabled("hello", false).unwrap();
f.host.set_theme(Some(("theme-solarized-dark", "solarized-dark"))).unwrap();
f.host.set_theme(Some(("theme-solarized", "solarized-dark"))).unwrap();
f.host.set_theme_frame(Some("#073642")).unwrap();
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
// Known before the plugins are loaded: new windows open in the theme's frame colour.
assert_eq!(again.startup_frame().as_deref(), Some("#073642"));
assert!(!again.wait_initial_load(Duration::from_millis(1)));
again.load_all();
assert!(again.wait_initial_load(Duration::ZERO));
let list = again.list(None);
let hello = list.plugins.iter().find(|p| p.id == "hello").unwrap();
assert!(!hello.enabled);
@@ -388,14 +438,14 @@ fn catalog_lists_sample_packages() {
let catalog = scan_catalog(&[repo().join("plugins"), tmp.path().to_path_buf()]);
let ids: Vec<&str> = catalog.iter().map(|c| c.id.as_str()).collect();
for id in ["hello", "theme-solarized-dark", "language-toy", "x"] {
for id in ["hello", "theme-solarized", "theme-one", "language-toy", "x"] {
assert!(ids.contains(&id), "{id} missing from {ids:?}");
}
let hello = catalog.iter().find(|c| c.id == "hello").unwrap();
assert!(hello.has_code && hello.problem.is_none() && hello.change_notes.is_some());
assert_eq!(hello.commands.len(), 2);
let theme = catalog.iter().find(|c| c.id == "theme-solarized-dark").unwrap();
assert_eq!((theme.theme_count, theme.has_code), (1, false));
let theme = catalog.iter().find(|c| c.id == "theme-solarized").unwrap();
assert_eq!((theme.theme_count, theme.has_code), (2, false));
let x = catalog.iter().find(|c| c.id == "x").unwrap();
assert!(x.problem.as_deref().unwrap().contains("plugin.wasm"));
}
+28 -2
View File
@@ -16,6 +16,31 @@ my-theme/
└─ my-theme.scheme.json # editor colours, optional (IntelliJ: .icls)
```
## Ready-made theme plugins
The repository's [`plugins/`](../../plugins) folder holds two complete theme
plugins, each with a light and a dark theme and an editor colour scheme
(syntax, editor and terminal ANSI colours):
| Package | Themes | Palette |
|---|---|---|
| [`plugins/theme-solarized`](../../plugins/theme-solarized) | Solarized Light, Solarized Dark | [Solarized](https://github.com/altercation/solarized) by Ethan Schoonover |
| [`plugins/theme-one`](../../plugins/theme-one) | One Light, One Dark | Atom [One Light](https://github.com/atom/one-light-syntax) / [One Dark](https://github.com/atom/one-dark-syntax) by GitHub |
Both palettes are MIT-licensed; each package carries the notice in its
`LICENSE`, which is installed with it. To use one:
1. *Settings › Plugins › ⚙ › Install Plugin from Disk…* and pick the package
folder (`plugins/theme-solarized` or `plugins/theme-one` of a checkout or a
downloaded copy). Development builds also list both on the *Available*
tab, where *Install* does the same.
2. Select the plugin and press *Apply* next to a theme.
The IDE remembers the theme: the next start-up opens its windows, splash and
workbench in the theme's colours before the plugin host has loaded.
## Writing a theme plugin
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
@@ -282,8 +307,9 @@ A `fontStyle: "italic"` on comments maps to `"--syn-comment-style": "italic"`.
- 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.
validates the template and the theme plugins in `plugins/` against
tokens.css and keeps this reference current, and `cargo test -p ide-plugin`
loads them through the plugin host.
## Token reference
+2 -1
View File
@@ -11,7 +11,8 @@ code at all.
plugins/
├─ build.sh # builds Rust plugins into components
├─ hello/ # Rust plugin: two commands
├─ theme-solarized-dark/ # manifest-only: a theme
├─ theme-solarized/ # manifest-only: Solarized Light / Dark themes
├─ theme-one/ # manifest-only: One Light / Dark themes
└─ language-toy/ # manifest-only: a language definition
```
+60
View File
@@ -0,0 +1,60 @@
The One Light and One Dark palettes are adapted from GitHub's Atom One themes
(styles/colors.less, styles/syntax-variables.less, styles/ui-variables.less):
- https://github.com/atom/one-light-syntax (revision d84579027410c576086dfca14d934c4bd74b0438)
- https://github.com/atom/one-dark-syntax (revision 9c96f4454362267ac45322063e193ccf9d2debb1)
- https://github.com/atom/one-light-ui (revision a27bce35088ecd323e44f5c2510bcf2d4e9efcf8)
- https://github.com/atom/one-dark-ui (revision 18c2143d41e17b337fff6dc7024562c4d999be17)
The exact HSL definitions are kept. Atom defines no terminal palette, so the
ANSI slots reuse One's syntax hues. Adapted to OpenIDEA's design tokens by bghtyu.
## one-light-syntax, one-dark-syntax
MIT License
Copyright (c) 2016 GitHub Inc.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## one-light-ui, one-dark-ui
MIT License
Copyright (c) 2014 GitHub Inc.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+17
View File
@@ -0,0 +1,17 @@
# Contribution-only theme plugin: no code, just theme and editor scheme files.
id = "theme-one"
name = "One Themes"
version = "1.0.0"
description = "One Light and One Dark: the Atom One palettes by GitHub for the UI, the editor and the terminal."
authors = ["bghtyu <3931591+bghtyu@users.noreply.github.com>", "GitHub Inc. (Atom One palettes)"]
homepage = "https://github.com/atom/one-dark-syntax"
change-notes = "1.0.0 — One Light and One Dark: UI, editor colour scheme and terminal ANSI colours."
openidea-api = "0.1"
[[contributes.themes]]
path = "themes/one-light.theme.json"
editor-scheme = "themes/one-light.scheme.json"
[[contributes.themes]]
path = "themes/one-dark.theme.json"
editor-scheme = "themes/one-dark.scheme.json"
@@ -0,0 +1,103 @@
// Atom One palette by GitHub Inc. (https://github.com/atom/one-dark-syntax and friends),
// MIT licence, see LICENSE. Adapted to OpenIDEA's tokens by bghtyu.
// Editor colour scheme: syntax, editor surfaces and terminal ANSI colours.
{
"name": "One Dark",
"tokens": {
// Syntax
"--syn-keyword": "hsl(286, 60%, 67%)",
"--syn-string": "hsl(95, 38%, 62%)",
"--syn-type": "hsl(39, 67%, 69%)",
"--syn-function": "hsl(207, 82%, 66%)",
"--syn-function-call": "hsl(207, 82%, 66%)",
"--syn-comment": "hsl(220, 10%, 40%)",
"--syn-comment-style": "italic",
"--syn-doc-comment": "hsl(220, 10%, 40%)",
"--syn-docstring": "hsl(95, 38%, 62%)",
"--syn-annotation": "hsl(29, 54%, 61%)",
"--syn-decorator": "hsl(29, 54%, 61%)",
"--syn-number": "hsl(29, 54%, 61%)",
"--syn-punct": "hsl(220, 14%, 71%)",
"--syn-variable": "hsl(355, 65%, 65%)",
"--syn-param": "hsl(220, 14%, 71%)",
"--syn-property": "hsl(220, 14%, 71%)",
"--syn-constant": "hsl(29, 54%, 61%)",
"--syn-builtin": "hsl(187, 47%, 55%)",
"--syn-self": "hsl(355, 65%, 65%)",
"--syn-keyword-arg": "hsl(355, 65%, 65%)",
"--syn-predefined": "hsl(187, 47%, 55%)",
"--syn-escape": "hsl(187, 47%, 55%)",
"--syn-regexp": "hsl(187, 47%, 55%)",
"--syn-tag": "hsl(355, 65%, 65%)",
"--syn-attribute": "hsl(29, 54%, 61%)",
"--syn-todo": "hsl(286, 60%, 67%)",
"--syn-link": "hsl(207, 82%, 66%)",
"--syn-markup-heading": "hsl(207, 82%, 66%)",
"--syn-markup-code": "hsl(95, 38%, 62%)",
"--syn-invalid": "hsl(355, 65%, 65%)",
"--syn-inline-value": "hsl(220, 9%, 55%)",
"--syn-rainbow-1": "hsl(187, 47%, 55%)",
"--syn-rainbow-2": "hsl(29, 54%, 61%)",
"--syn-rainbow-3": "hsl(95, 38%, 62%)",
"--syn-rainbow-4": "hsl(286, 60%, 67%)",
"--syn-rainbow-5": "hsl(207, 82%, 66%)",
"--syn-bracket-1": "hsl(39, 67%, 69%)",
"--syn-bracket-2": "hsl(286, 60%, 67%)",
"--syn-bracket-3": "hsl(207, 82%, 66%)",
// Editor
"--fg-editor": "hsl(220, 14%, 71%)",
"--caret": "hsl(220, 100%, 66%)",
"--fg-line-number": "hsl(220, 9%, 55%)",
"--fg-line-number-current": "hsl(220, 14%, 71%)",
"--border-gutter": "hsl(220, 13%, 10.8%)",
"--border-indent-guide": "hsl(220, 13%, 10.8%)",
"--border-indent-guide-active": "hsl(220, 100%, 66%)",
"--border-right-margin": "hsl(220, 13%, 10.8%)",
"--bg-editor-selection": "hsl(220, 13%, 28%)",
"--bg-editor-selection-inactive": "hsl(220, 13%, 28%)",
"--bg-text-search-result": "color-mix(in srgb, hsl(39, 67%, 69%) 22%, hsl(220, 13%, 18%))",
"--bg-text-search-result-current": "color-mix(in srgb, hsl(39, 67%, 69%) 36%, hsl(220, 13%, 18%))",
"--bg-matched-brace": "hsl(220, 13%, 28%)",
"--matched-brace-weight": "var(--fw-code-bold)",
"--bg-matched-tag": "hsl(220, 13%, 28%)",
"--bg-fold-placeholder": "hsl(220, 13%, 15%)",
"--fg-fold-placeholder": "hsl(220, 14%, 71%)",
"--fg-fold-marker": "hsl(220, 9%, 55%)",
"--fg-whitespace": "hsl(220, 9%, 55%)",
"--fg-unused": "hsl(220, 9%, 55%)",
"--bg-identifier-under-caret": "hsl(220, 13%, 28%)",
"--bg-identifier-under-caret-write": "color-mix(in srgb, hsl(355, 65%, 65%) 12%, hsl(220, 13%, 18%))",
"--fg-inlay": "hsl(220, 9%, 55%)",
"--bg-inlay": "hsl(220, 13%, 15%)",
"--wave-error": "hsl(355, 65%, 65%)",
"--wave-warning": "hsl(39, 67%, 69%)",
"--editor-error-decoration": "underline wavy var(--wave-error)",
"--editor-warning-decoration": "underline wavy var(--wave-warning)",
"--editor-warning-background": "color-mix(in srgb, hsl(39, 67%, 69%) 12%, hsl(220, 13%, 18%))",
"--bg-breakpoint-line": "color-mix(in srgb, hsl(355, 65%, 65%) 12%, hsl(220, 13%, 18%))",
"--fg-completion-match": "hsl(207, 82%, 66%)",
"--bg-editor-inline": "hsl(220, 13%, 15%)",
"--bg-editor-banner": "color-mix(in srgb, hsl(207, 82%, 66%) 12%, hsl(220, 13%, 18%))",
"--fg-weak-warning": "hsl(220, 9%, 55%)",
"--fg-bulb": "hsl(39, 67%, 69%)",
// ANSI palette
"--ansi-black": "hsl(220, 13%, 15%)",
"--ansi-red": "hsl(355, 65%, 65%)",
"--ansi-green": "hsl(95, 38%, 62%)",
"--ansi-yellow": "hsl(39, 67%, 69%)",
"--ansi-blue": "hsl(207, 82%, 66%)",
"--ansi-magenta": "hsl(286, 60%, 67%)",
"--ansi-cyan": "hsl(187, 47%, 55%)",
"--ansi-white": "hsl(220, 14%, 71%)",
"--ansi-bright-black": "hsl(220, 9%, 55%)",
"--ansi-bright-red": "hsl(355, 65%, 65%)",
"--ansi-bright-green": "hsl(95, 38%, 62%)",
"--ansi-bright-yellow": "hsl(39, 67%, 69%)",
"--ansi-bright-blue": "hsl(207, 82%, 66%)",
"--ansi-bright-magenta": "hsl(286, 60%, 67%)",
"--ansi-bright-cyan": "hsl(187, 47%, 55%)",
"--ansi-bright-white": "#ffffff"
}
}
@@ -0,0 +1,142 @@
// Atom One palette by GitHub Inc. (https://github.com/atom/one-dark-syntax and friends),
// MIT licence, see LICENSE. Adapted to OpenIDEA's tokens by bghtyu.
// UI theme; the editor colour scheme is in one-dark.scheme.json.
{
"id": "one-dark",
"name": "One Dark",
"kind": "dark",
"tokens": {
// Surfaces
"--bg-app": "hsl(220, 13%, 18%)",
"--bg-elev": "hsl(220, 13%, 15%)",
"--bg-popup": "hsl(220, 13%, 16.5%)",
"--bg-hover": "hsl(220, 13%, 22%)",
"--bg-selected": "hsl(220, 13%, 28%)",
"--bg-selected-strong": "hsl(220, 13%, 28%)",
"--bg-current-line": "color-mix(in srgb, hsl(220, 100%, 80%) 4%, hsl(220, 13%, 18%))",
"--bg-debug-line": "color-mix(in srgb, hsl(207, 82%, 66%) 12%, hsl(220, 13%, 18%))",
"--bg-backdrop": "hsl(220, 13%, 15%)",
"--bg-input": "hsl(220, 13%, 18%)",
// Lines
"--border": "hsl(220, 13%, 10.8%)",
"--border-popup": "hsl(220, 13%, 10.8%)",
"--border-strong": "hsl(220, 9%, 55%)",
"--border-window": "hsl(220, 13%, 10.8%)",
"--control-off": "hsl(220, 9%, 55%)",
// Text
"--fg-strong": "hsl(220, 14%, 71%)",
"--fg-emphasis": "hsl(220, 14%, 71%)",
"--fg": "hsl(220, 14%, 71%)",
"--fg-secondary": "hsl(220, 9%, 55%)",
"--fg-muted": "hsl(220, 9%, 55%)",
"--fg-subtle": "hsl(220, 9%, 55%)",
"--fg-faint": "hsl(220, 9%, 55%)",
"--fg-gutter": "hsl(220, 9%, 55%)",
"--fg-icon": "hsl(220, 14%, 71%)",
// Accent
"--accent": "hsl(220, 100%, 66%)",
"--link": "hsl(207, 82%, 66%)",
"--link-hover": "hsl(187, 47%, 55%)",
// Charts
"--chart-series": "hsl(355, 65%, 65%)",
// Status
"--danger": "hsl(355, 65%, 65%)",
"--warning": "hsl(39, 67%, 69%)",
"--success": "hsl(95, 38%, 62%)",
"--db-connected": "hsl(95, 38%, 62%)",
"--db-connected-ring": "hsl(95, 38%, 62%)",
"--info": "hsl(207, 82%, 66%)",
// Vcs
"--vcs-modified": "hsl(207, 82%, 66%)",
"--vcs-added": "hsl(95, 38%, 62%)",
"--vcs-deleted": "hsl(220, 9%, 55%)",
"--vcs-untracked": "hsl(355, 65%, 65%)",
"--vcs-ignored": "hsl(39, 67%, 69%)",
"--vcs-conflict": "hsl(355, 65%, 65%)",
"--gutter-modified": "hsl(207, 82%, 66%)",
"--gutter-added": "hsl(95, 38%, 62%)",
"--gutter-deleted": "hsl(355, 65%, 65%)",
// Shadows and window controls
"--shadow-popup": "0 4px 16px #00000024",
"--shadow-dialog": "0 10px 36px #00000033",
"--traffic-close": "#ff5f57",
"--traffic-close-border": "#e0443e",
"--traffic-close-pressed": "#bf4942",
"--traffic-glyph": "#4d0000",
"--traffic-off": "hsl(220, 13%, 22%)",
"--traffic-off-border": "hsl(220, 13%, 10.8%)",
// Additive
"--bg-tooltip": "hsl(220, 13%, 16.5%)",
"--fg-tooltip": "hsl(220, 14%, 71%)",
"--border-tooltip": "hsl(220, 13%, 10.8%)",
"--bg-control": "hsl(220, 13%, 18%)",
"--bg-control-hover": "hsl(220, 13%, 22%)",
"--bg-active": "hsl(220, 13%, 22%)",
"--bg-scrim": "color-mix(in srgb, hsl(220, 13%, 18%) 68%, transparent)",
"--fg-on-accent": "#000000",
"--shadow-tooltip": "0 4px 16px #00000024",
"--shadow-balloon": "0 10px 36px #00000033",
// Islands contract
"--bg-frame": "hsl(220, 13%, 15%)",
"--bg-island": "hsl(220, 13%, 18%)",
"--bg-stripe-hover": "hsl(220, 13%, 22%)",
"--bg-stripe-selected": "hsl(220, 13%, 28%)",
"--fg-stripe-selected-active": "#000000",
"--fg-stripe-label": "hsl(220, 14%, 71%)",
"--bg-tab-selected": "hsl(220, 13%, 28%)",
"--border-tab-selected": "hsl(220, 100%, 66%)",
"--bg-tab-selected-inactive": "hsl(220, 13%, 15%)",
"--border-tab-selected-inactive": "hsl(220, 13%, 10.8%)",
"--bg-tab-hover": "hsl(220, 13%, 22%)",
"--border-editor-tabs": "hsl(220, 13%, 10.8%)",
"--bg-tree-selected": "hsl(220, 13%, 28%)",
"--bg-tree-selected-inactive": "hsl(220, 13%, 22%)",
"--bg-tree-hover": "hsl(220, 13%, 22%)",
"--bg-toolbar-hover": "hsl(220, 13%, 22%)",
"--bg-toolbar-pressed": "hsl(220, 13%, 28%)",
"--bg-toolbar-selected": "hsl(220, 13%, 28%)",
"--bg-toolbar-selected-hover": "hsl(220, 13%, 28%)",
"--bg-button-primary-hover": "color-mix(in srgb, var(--accent) 86%, hsl(220, 14%, 71%))",
"--fg-button-primary": "#000000",
"--bg-button": "hsl(220, 13%, 18%)",
"--border-button": "hsl(220, 13%, 10.8%)",
"--bg-field": "hsl(220, 13%, 18%)",
"--bg-checkbox": "hsl(220, 13%, 18%)",
"--border-checkbox": "hsl(220, 9%, 55%)",
"--bg-toggle-on": "hsl(95, 38%, 62%)",
"--fg-toggle-on": "#000000",
"--bg-toggle-off": "hsl(220, 13%, 15%)",
"--fg-toggle-off": "hsl(220, 14%, 71%)",
"--border-toggle": "hsl(220, 9%, 55%)",
"--bg-toggle-knob": "hsl(220, 9%, 55%)",
"--focus-ring-soft": "hsl(220, 100%, 66%)",
"--scrollbar-thumb": "color-mix(in srgb, hsl(220, 14%, 71%) 24%, transparent)",
"--scrollbar-thumb-hover": "color-mix(in srgb, hsl(220, 14%, 71%) 42%, transparent)",
"--bg-search-match": "color-mix(in srgb, hsl(39, 67%, 69%) 22%, hsl(220, 13%, 18%))",
"--bg-search-match-current": "color-mix(in srgb, hsl(39, 67%, 69%) 36%, hsl(220, 13%, 18%))",
"--fg-search-match": "hsl(220, 14%, 71%)",
"--bg-dialog": "hsl(220, 13%, 18%)",
"--bg-popup-inline": "hsl(220, 13%, 15%)",
"--bg-popup-advertiser": "hsl(220, 13%, 15%)",
"--bg-feedback-info": "color-mix(in srgb, hsl(207, 82%, 66%) 12%, hsl(220, 13%, 18%))",
"--border-feedback-info": "color-mix(in srgb, hsl(207, 82%, 66%) 55%, hsl(220, 13%, 18%))",
"--bg-feedback-error": "color-mix(in srgb, hsl(355, 65%, 65%) 12%, hsl(220, 13%, 18%))",
"--border-feedback-error": "color-mix(in srgb, hsl(355, 65%, 65%) 55%, hsl(220, 13%, 18%))",
"--bg-feedback-warning": "color-mix(in srgb, hsl(39, 67%, 69%) 12%, hsl(220, 13%, 18%))",
"--border-feedback-warning": "color-mix(in srgb, hsl(39, 67%, 69%) 55%, hsl(220, 13%, 18%))",
"--bg-feedback-success": "color-mix(in srgb, hsl(95, 38%, 62%) 12%, hsl(220, 13%, 18%))",
"--border-feedback-success": "color-mix(in srgb, hsl(95, 38%, 62%) 55%, hsl(220, 13%, 18%))",
"--bg-counter": "hsl(220, 100%, 66%)",
"--fg-counter": "#000000",
"--bg-progress-track": "hsl(220, 13%, 22%)"
}
}
@@ -0,0 +1,103 @@
// Atom One palette by GitHub Inc. (https://github.com/atom/one-dark-syntax and friends),
// MIT licence, see LICENSE. Adapted to OpenIDEA's tokens by bghtyu.
// Editor colour scheme: syntax, editor surfaces and terminal ANSI colours.
{
"name": "One Light",
"tokens": {
// Syntax
"--syn-keyword": "hsl(301, 63%, 40%)",
"--syn-string": "hsl(119, 34%, 47%)",
"--syn-type": "hsl(41, 99%, 38%)",
"--syn-function": "hsl(221, 87%, 60%)",
"--syn-function-call": "hsl(221, 87%, 60%)",
"--syn-comment": "hsl(230, 4%, 64%)",
"--syn-comment-style": "italic",
"--syn-doc-comment": "hsl(230, 4%, 64%)",
"--syn-docstring": "hsl(119, 34%, 47%)",
"--syn-annotation": "hsl(41, 99%, 30%)",
"--syn-decorator": "hsl(41, 99%, 30%)",
"--syn-number": "hsl(41, 99%, 30%)",
"--syn-punct": "hsl(230, 8%, 24%)",
"--syn-variable": "hsl(5, 74%, 59%)",
"--syn-param": "hsl(230, 8%, 24%)",
"--syn-property": "hsl(230, 8%, 24%)",
"--syn-constant": "hsl(41, 99%, 30%)",
"--syn-builtin": "hsl(198, 99%, 37%)",
"--syn-self": "hsl(5, 74%, 59%)",
"--syn-keyword-arg": "hsl(5, 74%, 59%)",
"--syn-predefined": "hsl(198, 99%, 37%)",
"--syn-escape": "hsl(198, 99%, 37%)",
"--syn-regexp": "hsl(198, 99%, 37%)",
"--syn-tag": "hsl(5, 74%, 59%)",
"--syn-attribute": "hsl(41, 99%, 30%)",
"--syn-todo": "hsl(301, 63%, 40%)",
"--syn-link": "hsl(221, 87%, 60%)",
"--syn-markup-heading": "hsl(221, 87%, 60%)",
"--syn-markup-code": "hsl(119, 34%, 47%)",
"--syn-invalid": "hsl(5, 74%, 59%)",
"--syn-inline-value": "hsl(230, 6%, 44%)",
"--syn-rainbow-1": "hsl(198, 99%, 37%)",
"--syn-rainbow-2": "hsl(41, 99%, 30%)",
"--syn-rainbow-3": "hsl(119, 34%, 47%)",
"--syn-rainbow-4": "hsl(301, 63%, 40%)",
"--syn-rainbow-5": "hsl(221, 87%, 60%)",
"--syn-bracket-1": "hsl(41, 99%, 38%)",
"--syn-bracket-2": "hsl(301, 63%, 40%)",
"--syn-bracket-3": "hsl(221, 87%, 60%)",
// Editor
"--fg-editor": "hsl(230, 8%, 24%)",
"--caret": "hsl(230, 100%, 66%)",
"--fg-line-number": "hsl(230, 6%, 44%)",
"--fg-line-number-current": "hsl(230, 8%, 24%)",
"--border-gutter": "hsl(230, 1%, 86%)",
"--border-indent-guide": "hsl(230, 1%, 86%)",
"--border-indent-guide-active": "hsl(230, 100%, 66%)",
"--border-right-margin": "hsl(230, 1%, 86%)",
"--bg-editor-selection": "hsl(230, 1%, 90%)",
"--bg-editor-selection-inactive": "hsl(230, 1%, 90%)",
"--bg-text-search-result": "color-mix(in srgb, hsl(41, 99%, 38%) 22%, hsl(230, 1%, 98%))",
"--bg-text-search-result-current": "color-mix(in srgb, hsl(41, 99%, 38%) 36%, hsl(230, 1%, 98%))",
"--bg-matched-brace": "hsl(230, 1%, 90%)",
"--matched-brace-weight": "var(--fw-code-bold)",
"--bg-matched-tag": "hsl(230, 1%, 90%)",
"--bg-fold-placeholder": "hsl(230, 1%, 92%)",
"--fg-fold-placeholder": "hsl(230, 8%, 24%)",
"--fg-fold-marker": "hsl(230, 6%, 44%)",
"--fg-whitespace": "hsl(230, 6%, 44%)",
"--fg-unused": "hsl(230, 6%, 44%)",
"--bg-identifier-under-caret": "hsl(230, 1%, 90%)",
"--bg-identifier-under-caret-write": "color-mix(in srgb, hsl(5, 74%, 59%) 12%, hsl(230, 1%, 98%))",
"--fg-inlay": "hsl(230, 6%, 44%)",
"--bg-inlay": "hsl(230, 1%, 92%)",
"--wave-error": "hsl(5, 74%, 59%)",
"--wave-warning": "hsl(41, 99%, 38%)",
"--editor-error-decoration": "underline wavy var(--wave-error)",
"--editor-warning-decoration": "underline wavy var(--wave-warning)",
"--editor-warning-background": "color-mix(in srgb, hsl(41, 99%, 38%) 12%, hsl(230, 1%, 98%))",
"--bg-breakpoint-line": "color-mix(in srgb, hsl(5, 74%, 59%) 12%, hsl(230, 1%, 98%))",
"--fg-completion-match": "hsl(221, 87%, 60%)",
"--bg-editor-inline": "hsl(230, 1%, 92%)",
"--bg-editor-banner": "color-mix(in srgb, hsl(221, 87%, 60%) 12%, hsl(230, 1%, 98%))",
"--fg-weak-warning": "hsl(230, 6%, 44%)",
"--fg-bulb": "hsl(41, 99%, 38%)",
// ANSI palette
"--ansi-black": "hsl(230, 8%, 24%)",
"--ansi-red": "hsl(5, 74%, 59%)",
"--ansi-green": "hsl(119, 34%, 47%)",
"--ansi-yellow": "hsl(41, 99%, 38%)",
"--ansi-blue": "hsl(221, 87%, 60%)",
"--ansi-magenta": "hsl(301, 63%, 40%)",
"--ansi-cyan": "hsl(198, 99%, 37%)",
"--ansi-white": "hsl(230, 6%, 44%)",
"--ansi-bright-black": "hsl(230, 6%, 44%)",
"--ansi-bright-red": "hsl(5, 74%, 59%)",
"--ansi-bright-green": "hsl(119, 34%, 47%)",
"--ansi-bright-yellow": "hsl(41, 99%, 38%)",
"--ansi-bright-blue": "hsl(221, 87%, 60%)",
"--ansi-bright-magenta": "hsl(301, 63%, 40%)",
"--ansi-bright-cyan": "hsl(198, 99%, 37%)",
"--ansi-bright-white": "hsl(230, 8%, 24%)"
}
}
@@ -0,0 +1,142 @@
// Atom One palette by GitHub Inc. (https://github.com/atom/one-dark-syntax and friends),
// MIT licence, see LICENSE. Adapted to OpenIDEA's tokens by bghtyu.
// UI theme; the editor colour scheme is in one-light.scheme.json.
{
"id": "one-light",
"name": "One Light",
"kind": "light",
"tokens": {
// Surfaces
"--bg-app": "hsl(230, 1%, 98%)",
"--bg-elev": "hsl(230, 1%, 92%)",
"--bg-popup": "hsl(230, 1%, 95%)",
"--bg-hover": "hsl(230, 1%, 90%)",
"--bg-selected": "hsl(230, 1%, 90%)",
"--bg-selected-strong": "hsl(230, 1%, 90%)",
"--bg-current-line": "color-mix(in srgb, hsl(230, 8%, 24%) 5%, hsl(230, 1%, 98%))",
"--bg-debug-line": "color-mix(in srgb, hsl(221, 87%, 60%) 12%, hsl(230, 1%, 98%))",
"--bg-backdrop": "hsl(230, 1%, 92%)",
"--bg-input": "hsl(230, 1%, 98%)",
// Lines
"--border": "hsl(230, 1%, 86%)",
"--border-popup": "hsl(230, 1%, 86%)",
"--border-strong": "hsl(230, 6%, 44%)",
"--border-window": "hsl(230, 1%, 86%)",
"--control-off": "hsl(230, 6%, 44%)",
// Text
"--fg-strong": "hsl(230, 8%, 24%)",
"--fg-emphasis": "hsl(230, 8%, 24%)",
"--fg": "hsl(230, 8%, 24%)",
"--fg-secondary": "hsl(230, 6%, 44%)",
"--fg-muted": "hsl(230, 6%, 44%)",
"--fg-subtle": "hsl(230, 6%, 44%)",
"--fg-faint": "hsl(230, 6%, 44%)",
"--fg-gutter": "hsl(230, 6%, 44%)",
"--fg-icon": "hsl(230, 8%, 24%)",
// Accent
"--accent": "hsl(230, 100%, 66%)",
"--link": "hsl(221, 87%, 60%)",
"--link-hover": "hsl(198, 99%, 37%)",
// Charts
"--chart-series": "hsl(5, 74%, 59%)",
// Status
"--danger": "hsl(5, 74%, 59%)",
"--warning": "hsl(41, 99%, 38%)",
"--success": "hsl(119, 34%, 47%)",
"--db-connected": "hsl(119, 34%, 47%)",
"--db-connected-ring": "hsl(119, 34%, 47%)",
"--info": "hsl(221, 87%, 60%)",
// Vcs
"--vcs-modified": "hsl(221, 87%, 60%)",
"--vcs-added": "hsl(119, 34%, 47%)",
"--vcs-deleted": "hsl(230, 6%, 44%)",
"--vcs-untracked": "hsl(5, 74%, 59%)",
"--vcs-ignored": "hsl(41, 99%, 38%)",
"--vcs-conflict": "hsl(5, 74%, 59%)",
"--gutter-modified": "hsl(221, 87%, 60%)",
"--gutter-added": "hsl(119, 34%, 47%)",
"--gutter-deleted": "hsl(5, 74%, 59%)",
// Shadows and window controls
"--shadow-popup": "0 4px 16px #00000024",
"--shadow-dialog": "0 10px 36px #00000033",
"--traffic-close": "#ff5f57",
"--traffic-close-border": "#e0443e",
"--traffic-close-pressed": "#bf4942",
"--traffic-glyph": "#4d0000",
"--traffic-off": "hsl(230, 1%, 90%)",
"--traffic-off-border": "hsl(230, 1%, 86%)",
// Additive
"--bg-tooltip": "hsl(230, 1%, 95%)",
"--fg-tooltip": "hsl(230, 8%, 24%)",
"--border-tooltip": "hsl(230, 1%, 86%)",
"--bg-control": "hsl(230, 1%, 98%)",
"--bg-control-hover": "hsl(230, 1%, 90%)",
"--bg-active": "hsl(230, 1%, 90%)",
"--bg-scrim": "color-mix(in srgb, hsl(230, 1%, 98%) 68%, transparent)",
"--fg-on-accent": "#ffffff",
"--shadow-tooltip": "0 4px 16px #00000024",
"--shadow-balloon": "0 10px 36px #00000033",
// Islands contract
"--bg-frame": "hsl(230, 1%, 92%)",
"--bg-island": "hsl(230, 1%, 98%)",
"--bg-stripe-hover": "hsl(230, 1%, 90%)",
"--bg-stripe-selected": "hsl(230, 1%, 90%)",
"--fg-stripe-selected-active": "#ffffff",
"--fg-stripe-label": "hsl(230, 8%, 24%)",
"--bg-tab-selected": "hsl(230, 1%, 90%)",
"--border-tab-selected": "hsl(230, 100%, 66%)",
"--bg-tab-selected-inactive": "hsl(230, 1%, 92%)",
"--border-tab-selected-inactive": "hsl(230, 1%, 86%)",
"--bg-tab-hover": "hsl(230, 1%, 90%)",
"--border-editor-tabs": "hsl(230, 1%, 86%)",
"--bg-tree-selected": "hsl(230, 1%, 90%)",
"--bg-tree-selected-inactive": "hsl(230, 1%, 90%)",
"--bg-tree-hover": "hsl(230, 1%, 90%)",
"--bg-toolbar-hover": "hsl(230, 1%, 90%)",
"--bg-toolbar-pressed": "hsl(230, 1%, 90%)",
"--bg-toolbar-selected": "hsl(230, 1%, 90%)",
"--bg-toolbar-selected-hover": "hsl(230, 1%, 90%)",
"--bg-button-primary-hover": "color-mix(in srgb, var(--accent) 86%, hsl(230, 8%, 24%))",
"--fg-button-primary": "#ffffff",
"--bg-button": "hsl(230, 1%, 98%)",
"--border-button": "hsl(230, 1%, 86%)",
"--bg-field": "hsl(230, 1%, 98%)",
"--bg-checkbox": "hsl(230, 1%, 98%)",
"--border-checkbox": "hsl(230, 6%, 44%)",
"--bg-toggle-on": "hsl(119, 34%, 47%)",
"--fg-toggle-on": "#ffffff",
"--bg-toggle-off": "hsl(230, 1%, 92%)",
"--fg-toggle-off": "hsl(230, 8%, 24%)",
"--border-toggle": "hsl(230, 6%, 44%)",
"--bg-toggle-knob": "hsl(230, 6%, 44%)",
"--focus-ring-soft": "hsl(230, 100%, 66%)",
"--scrollbar-thumb": "color-mix(in srgb, hsl(230, 8%, 24%) 24%, transparent)",
"--scrollbar-thumb-hover": "color-mix(in srgb, hsl(230, 8%, 24%) 42%, transparent)",
"--bg-search-match": "color-mix(in srgb, hsl(41, 99%, 38%) 22%, hsl(230, 1%, 98%))",
"--bg-search-match-current": "color-mix(in srgb, hsl(41, 99%, 38%) 36%, hsl(230, 1%, 98%))",
"--fg-search-match": "hsl(230, 8%, 24%)",
"--bg-dialog": "hsl(230, 1%, 98%)",
"--bg-popup-inline": "hsl(230, 1%, 92%)",
"--bg-popup-advertiser": "hsl(230, 1%, 92%)",
"--bg-feedback-info": "color-mix(in srgb, hsl(221, 87%, 60%) 12%, hsl(230, 1%, 98%))",
"--border-feedback-info": "color-mix(in srgb, hsl(221, 87%, 60%) 55%, hsl(230, 1%, 98%))",
"--bg-feedback-error": "color-mix(in srgb, hsl(5, 74%, 59%) 12%, hsl(230, 1%, 98%))",
"--border-feedback-error": "color-mix(in srgb, hsl(5, 74%, 59%) 55%, hsl(230, 1%, 98%))",
"--bg-feedback-warning": "color-mix(in srgb, hsl(41, 99%, 38%) 12%, hsl(230, 1%, 98%))",
"--border-feedback-warning": "color-mix(in srgb, hsl(41, 99%, 38%) 55%, hsl(230, 1%, 98%))",
"--bg-feedback-success": "color-mix(in srgb, hsl(119, 34%, 47%) 12%, hsl(230, 1%, 98%))",
"--border-feedback-success": "color-mix(in srgb, hsl(119, 34%, 47%) 55%, hsl(230, 1%, 98%))",
"--bg-counter": "hsl(230, 100%, 66%)",
"--fg-counter": "#ffffff",
"--bg-progress-track": "hsl(230, 1%, 90%)"
}
}
-11
View File
@@ -1,11 +0,0 @@
# Contribution-only plugin: no code, just a theme file.
id = "theme-solarized-dark"
name = "Solarized Dark Theme"
version = "1.0.0"
description = "Ethan Schoonover's Solarized palette, dark variant."
authors = ["OpenIDEA contributors"]
change-notes = "1.0.0 — Solarized Dark UI and syntax colours."
openidea-api = "0.1"
[[contributes.themes]]
path = "themes/solarized-dark.json"
@@ -1,64 +0,0 @@
{
"id": "solarized-dark",
"name": "Solarized Dark",
"kind": "dark",
"tokens": {
"--bg-app": "#002b36",
"--bg-elev": "#03313c",
"--bg-popup": "#073642",
"--bg-hover": "#0a3d4a",
"--bg-selected": "#0d4554",
"--bg-selected-strong": "#104d5d",
"--bg-current-line": "#04323d",
"--bg-debug-line": "#0b3a50",
"--bg-backdrop": "#00212a",
"--bg-tooltip": "#073642",
"--bg-control": "#073642",
"--bg-control-hover": "#0a3d4a",
"--bg-active": "#104d5d",
"--border": "#06323d",
"--border-popup": "#0a3d4a",
"--border-strong": "#124b59",
"--border-window": "#0a3d4a",
"--control-off": "#2c5662",
"--fg-strong": "#eee8d5",
"--fg-emphasis": "#d9d4c1",
"--fg": "#93a1a1",
"--fg-secondary": "#839496",
"--fg-muted": "#839496",
"--fg-subtle": "#657b83",
"--fg-faint": "#586e75",
"--fg-gutter": "#4c636b",
"--fg-tooltip": "#eee8d5",
"--fg-on-accent": "#002b36",
"--accent": "#268bd2",
"--link": "#268bd2",
"--link-hover": "#5fa9e0",
"--danger": "#dc322f",
"--warning": "#b58900",
"--success": "#859900",
"--info": "#268bd2",
"--vcs-modified": "#b58900",
"--vcs-added": "#859900",
"--vcs-deleted": "#657b83",
"--vcs-untracked": "#cb4b16",
"--vcs-ignored": "#586e75",
"--vcs-conflict": "#d33682",
"--gutter-modified": "#268bd2",
"--gutter-added": "#859900",
"--gutter-deleted": "#dc322f",
"--syn-keyword": "#859900",
"--syn-string": "#2aa198",
"--syn-type": "#b58900",
"--syn-function": "#268bd2",
"--syn-comment": "#586e75",
"--syn-annotation": "#6c71c4",
"--syn-number": "#d33682",
"--syn-punct": "#839496",
"--syn-variable": "#93a1a1",
"--syn-property": "#93a1a1",
"--syn-constant": "#cb4b16",
"--syn-invalid": "#dc322f",
"--syn-inline-value": "#657b83"
}
}
+27
View File
@@ -0,0 +1,27 @@
The Solarized palette is adapted from Ethan Schoonover's Solarized,
https://github.com/altercation/solarized (revision 62f656a02f93c5190a8753159e34b385588d5ff3):
the sixteen sRGB colours of its README, the semantic roles of
vim-colors-solarized/colors/solarized.vim and its iTerm2 ANSI slots.
Adapted to OpenIDEA's design tokens by bghtyu.
MIT License
Copyright (c) 2011 Ethan Schoonover
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+17
View File
@@ -0,0 +1,17 @@
# Contribution-only theme plugin: no code, just theme and editor scheme files.
id = "theme-solarized"
name = "Solarized Themes"
version = "1.0.0"
description = "Solarized Light and Solarized Dark: Ethan Schoonover's Solarized palette for the UI, the editor and the terminal."
authors = ["bghtyu <3931591+bghtyu@users.noreply.github.com>", "Ethan Schoonover (Solarized palette)"]
homepage = "https://github.com/altercation/solarized"
change-notes = "1.0.0 — Solarized Light and Solarized Dark: UI, editor colour scheme and terminal ANSI colours."
openidea-api = "0.1"
[[contributes.themes]]
path = "themes/solarized-light.theme.json"
editor-scheme = "themes/solarized-light.scheme.json"
[[contributes.themes]]
path = "themes/solarized-dark.theme.json"
editor-scheme = "themes/solarized-dark.scheme.json"
@@ -0,0 +1,103 @@
// Solarized palette by Ethan Schoonover (https://github.com/altercation/solarized),
// MIT licence, see LICENSE. Adapted to OpenIDEA's tokens by bghtyu.
// Editor colour scheme: syntax, editor surfaces and terminal ANSI colours.
{
"name": "Solarized Dark",
"tokens": {
// Syntax
"--syn-keyword": "#859900",
"--syn-string": "#2aa198",
"--syn-type": "#b58900",
"--syn-function": "#268bd2",
"--syn-function-call": "#268bd2",
"--syn-comment": "#586e75",
"--syn-comment-style": "italic",
"--syn-doc-comment": "#586e75",
"--syn-docstring": "#2aa198",
"--syn-annotation": "#cb4b16",
"--syn-decorator": "#cb4b16",
"--syn-number": "#2aa198",
"--syn-punct": "#839496",
"--syn-variable": "#268bd2",
"--syn-param": "#839496",
"--syn-property": "#268bd2",
"--syn-constant": "#2aa198",
"--syn-builtin": "#2aa198",
"--syn-self": "#dc322f",
"--syn-keyword-arg": "#dc322f",
"--syn-predefined": "#2aa198",
"--syn-escape": "#dc322f",
"--syn-regexp": "#2aa198",
"--syn-tag": "#268bd2",
"--syn-attribute": "#cb4b16",
"--syn-todo": "#d33682",
"--syn-link": "#268bd2",
"--syn-markup-heading": "#268bd2",
"--syn-markup-code": "#2aa198",
"--syn-invalid": "#dc322f",
"--syn-inline-value": "#839496",
"--syn-rainbow-1": "#2aa198",
"--syn-rainbow-2": "#cb4b16",
"--syn-rainbow-3": "#859900",
"--syn-rainbow-4": "#d33682",
"--syn-rainbow-5": "#268bd2",
"--syn-bracket-1": "#b58900",
"--syn-bracket-2": "#d33682",
"--syn-bracket-3": "#268bd2",
// Editor
"--fg-editor": "#839496",
"--caret": "#268bd2",
"--fg-line-number": "#839496",
"--fg-line-number-current": "#839496",
"--border-gutter": "#073642",
"--border-indent-guide": "#073642",
"--border-indent-guide-active": "#268bd2",
"--border-right-margin": "#073642",
"--bg-editor-selection": "#073642",
"--bg-editor-selection-inactive": "#073642",
"--bg-text-search-result": "color-mix(in srgb, #b58900 22%, #002b36)",
"--bg-text-search-result-current": "color-mix(in srgb, #b58900 36%, #002b36)",
"--bg-matched-brace": "#073642",
"--matched-brace-weight": "var(--fw-code-bold)",
"--bg-matched-tag": "#073642",
"--bg-fold-placeholder": "#073642",
"--fg-fold-placeholder": "#839496",
"--fg-fold-marker": "#839496",
"--fg-whitespace": "#839496",
"--fg-unused": "#839496",
"--bg-identifier-under-caret": "#073642",
"--bg-identifier-under-caret-write": "color-mix(in srgb, #dc322f 12%, #002b36)",
"--fg-inlay": "#839496",
"--bg-inlay": "#073642",
"--wave-error": "#dc322f",
"--wave-warning": "#b58900",
"--editor-error-decoration": "underline wavy var(--wave-error)",
"--editor-warning-decoration": "underline wavy var(--wave-warning)",
"--editor-warning-background": "color-mix(in srgb, #b58900 12%, #002b36)",
"--bg-breakpoint-line": "color-mix(in srgb, #dc322f 12%, #002b36)",
"--fg-completion-match": "#268bd2",
"--bg-editor-inline": "#073642",
"--bg-editor-banner": "color-mix(in srgb, #268bd2 12%, #002b36)",
"--fg-weak-warning": "#839496",
"--fg-bulb": "#b58900",
// ANSI palette
"--ansi-black": "#073642",
"--ansi-red": "#dc322f",
"--ansi-green": "#859900",
"--ansi-yellow": "#b58900",
"--ansi-blue": "#268bd2",
"--ansi-magenta": "#d33682",
"--ansi-cyan": "#2aa198",
"--ansi-white": "#eee8d5",
"--ansi-bright-black": "#002b36",
"--ansi-bright-red": "#cb4b16",
"--ansi-bright-green": "#586e75",
"--ansi-bright-yellow": "#657b83",
"--ansi-bright-blue": "#839496",
"--ansi-bright-magenta": "#6c71c4",
"--ansi-bright-cyan": "#93a1a1",
"--ansi-bright-white": "#fdf6e3"
}
}
@@ -0,0 +1,142 @@
// Solarized palette by Ethan Schoonover (https://github.com/altercation/solarized),
// MIT licence, see LICENSE. Adapted to OpenIDEA's tokens by bghtyu.
// UI theme; the editor colour scheme is in solarized-dark.scheme.json.
{
"id": "solarized-dark",
"name": "Solarized Dark",
"kind": "dark",
"tokens": {
// Surfaces
"--bg-app": "#002b36",
"--bg-elev": "#073642",
"--bg-popup": "#073642",
"--bg-hover": "#073642",
"--bg-selected": "#073642",
"--bg-selected-strong": "#073642",
"--bg-current-line": "#073642",
"--bg-debug-line": "color-mix(in srgb, #268bd2 12%, #002b36)",
"--bg-backdrop": "#073642",
"--bg-input": "#002b36",
// Lines
"--border": "#073642",
"--border-popup": "#073642",
"--border-strong": "#839496",
"--border-window": "#073642",
"--control-off": "#839496",
// Text
"--fg-strong": "#839496",
"--fg-emphasis": "#839496",
"--fg": "#839496",
"--fg-secondary": "#839496",
"--fg-muted": "#839496",
"--fg-subtle": "#839496",
"--fg-faint": "#839496",
"--fg-gutter": "#839496",
"--fg-icon": "#839496",
// Accent
"--accent": "#268bd2",
"--link": "#268bd2",
"--link-hover": "#2aa198",
// Charts
"--chart-series": "#dc322f",
// Status
"--danger": "#dc322f",
"--warning": "#b58900",
"--success": "#859900",
"--db-connected": "#859900",
"--db-connected-ring": "#859900",
"--info": "#268bd2",
// Vcs
"--vcs-modified": "#268bd2",
"--vcs-added": "#859900",
"--vcs-deleted": "#839496",
"--vcs-untracked": "#dc322f",
"--vcs-ignored": "#b58900",
"--vcs-conflict": "#dc322f",
"--gutter-modified": "#268bd2",
"--gutter-added": "#859900",
"--gutter-deleted": "#dc322f",
// Shadows and window controls
"--shadow-popup": "0 4px 16px #00000024",
"--shadow-dialog": "0 10px 36px #00000033",
"--traffic-close": "#ff5f57",
"--traffic-close-border": "#e0443e",
"--traffic-close-pressed": "#bf4942",
"--traffic-glyph": "#4d0000",
"--traffic-off": "#073642",
"--traffic-off-border": "#073642",
// Additive
"--bg-tooltip": "#073642",
"--fg-tooltip": "#839496",
"--border-tooltip": "#073642",
"--bg-control": "#002b36",
"--bg-control-hover": "#073642",
"--bg-active": "#073642",
"--bg-scrim": "color-mix(in srgb, #002b36 68%, transparent)",
"--fg-on-accent": "#002b36",
"--shadow-tooltip": "0 4px 16px #00000024",
"--shadow-balloon": "0 10px 36px #00000033",
// Islands contract
"--bg-frame": "#073642",
"--bg-island": "#002b36",
"--bg-stripe-hover": "#073642",
"--bg-stripe-selected": "#073642",
"--fg-stripe-selected-active": "#002b36",
"--fg-stripe-label": "#839496",
"--bg-tab-selected": "#073642",
"--border-tab-selected": "#268bd2",
"--bg-tab-selected-inactive": "#073642",
"--border-tab-selected-inactive": "#073642",
"--bg-tab-hover": "#073642",
"--border-editor-tabs": "#073642",
"--bg-tree-selected": "#073642",
"--bg-tree-selected-inactive": "#073642",
"--bg-tree-hover": "#073642",
"--bg-toolbar-hover": "#073642",
"--bg-toolbar-pressed": "#073642",
"--bg-toolbar-selected": "#073642",
"--bg-toolbar-selected-hover": "#073642",
"--bg-button-primary-hover": "color-mix(in srgb, var(--accent) 86%, #839496)",
"--fg-button-primary": "#002b36",
"--bg-button": "#002b36",
"--border-button": "#073642",
"--bg-field": "#002b36",
"--bg-checkbox": "#002b36",
"--border-checkbox": "#839496",
"--bg-toggle-on": "#859900",
"--fg-toggle-on": "#002b36",
"--bg-toggle-off": "#073642",
"--fg-toggle-off": "#839496",
"--border-toggle": "#839496",
"--bg-toggle-knob": "#839496",
"--focus-ring-soft": "#268bd2",
"--scrollbar-thumb": "color-mix(in srgb, #839496 24%, transparent)",
"--scrollbar-thumb-hover": "color-mix(in srgb, #839496 42%, transparent)",
"--bg-search-match": "color-mix(in srgb, #b58900 22%, #002b36)",
"--bg-search-match-current": "color-mix(in srgb, #b58900 36%, #002b36)",
"--fg-search-match": "#839496",
"--bg-dialog": "#002b36",
"--bg-popup-inline": "#073642",
"--bg-popup-advertiser": "#073642",
"--bg-feedback-info": "color-mix(in srgb, #268bd2 12%, #002b36)",
"--border-feedback-info": "color-mix(in srgb, #268bd2 55%, #002b36)",
"--bg-feedback-error": "color-mix(in srgb, #dc322f 12%, #002b36)",
"--border-feedback-error": "color-mix(in srgb, #dc322f 55%, #002b36)",
"--bg-feedback-warning": "color-mix(in srgb, #b58900 12%, #002b36)",
"--border-feedback-warning": "color-mix(in srgb, #b58900 55%, #002b36)",
"--bg-feedback-success": "color-mix(in srgb, #859900 12%, #002b36)",
"--border-feedback-success": "color-mix(in srgb, #859900 55%, #002b36)",
"--bg-counter": "#268bd2",
"--fg-counter": "#002b36",
"--bg-progress-track": "#073642"
}
}
@@ -0,0 +1,103 @@
// Solarized palette by Ethan Schoonover (https://github.com/altercation/solarized),
// MIT licence, see LICENSE. Adapted to OpenIDEA's tokens by bghtyu.
// Editor colour scheme: syntax, editor surfaces and terminal ANSI colours.
{
"name": "Solarized Light",
"tokens": {
// Syntax
"--syn-keyword": "#859900",
"--syn-string": "#2aa198",
"--syn-type": "#b58900",
"--syn-function": "#268bd2",
"--syn-function-call": "#268bd2",
"--syn-comment": "#93a1a1",
"--syn-comment-style": "italic",
"--syn-doc-comment": "#93a1a1",
"--syn-docstring": "#2aa198",
"--syn-annotation": "#cb4b16",
"--syn-decorator": "#cb4b16",
"--syn-number": "#2aa198",
"--syn-punct": "#657b83",
"--syn-variable": "#268bd2",
"--syn-param": "#657b83",
"--syn-property": "#268bd2",
"--syn-constant": "#2aa198",
"--syn-builtin": "#2aa198",
"--syn-self": "#dc322f",
"--syn-keyword-arg": "#dc322f",
"--syn-predefined": "#2aa198",
"--syn-escape": "#dc322f",
"--syn-regexp": "#2aa198",
"--syn-tag": "#268bd2",
"--syn-attribute": "#cb4b16",
"--syn-todo": "#d33682",
"--syn-link": "#268bd2",
"--syn-markup-heading": "#268bd2",
"--syn-markup-code": "#2aa198",
"--syn-invalid": "#dc322f",
"--syn-inline-value": "#657b83",
"--syn-rainbow-1": "#2aa198",
"--syn-rainbow-2": "#cb4b16",
"--syn-rainbow-3": "#859900",
"--syn-rainbow-4": "#d33682",
"--syn-rainbow-5": "#268bd2",
"--syn-bracket-1": "#b58900",
"--syn-bracket-2": "#d33682",
"--syn-bracket-3": "#268bd2",
// Editor
"--fg-editor": "#657b83",
"--caret": "#268bd2",
"--fg-line-number": "#657b83",
"--fg-line-number-current": "#657b83",
"--border-gutter": "#eee8d5",
"--border-indent-guide": "#eee8d5",
"--border-indent-guide-active": "#268bd2",
"--border-right-margin": "#eee8d5",
"--bg-editor-selection": "#eee8d5",
"--bg-editor-selection-inactive": "#eee8d5",
"--bg-text-search-result": "color-mix(in srgb, #b58900 22%, #fdf6e3)",
"--bg-text-search-result-current": "color-mix(in srgb, #b58900 36%, #fdf6e3)",
"--bg-matched-brace": "#eee8d5",
"--matched-brace-weight": "var(--fw-code-bold)",
"--bg-matched-tag": "#eee8d5",
"--bg-fold-placeholder": "#eee8d5",
"--fg-fold-placeholder": "#657b83",
"--fg-fold-marker": "#657b83",
"--fg-whitespace": "#657b83",
"--fg-unused": "#657b83",
"--bg-identifier-under-caret": "#eee8d5",
"--bg-identifier-under-caret-write": "color-mix(in srgb, #dc322f 12%, #fdf6e3)",
"--fg-inlay": "#657b83",
"--bg-inlay": "#eee8d5",
"--wave-error": "#dc322f",
"--wave-warning": "#b58900",
"--editor-error-decoration": "underline wavy var(--wave-error)",
"--editor-warning-decoration": "underline wavy var(--wave-warning)",
"--editor-warning-background": "color-mix(in srgb, #b58900 12%, #fdf6e3)",
"--bg-breakpoint-line": "color-mix(in srgb, #dc322f 12%, #fdf6e3)",
"--fg-completion-match": "#268bd2",
"--bg-editor-inline": "#eee8d5",
"--bg-editor-banner": "color-mix(in srgb, #268bd2 12%, #fdf6e3)",
"--fg-weak-warning": "#657b83",
"--fg-bulb": "#b58900",
// ANSI palette
"--ansi-black": "#073642",
"--ansi-red": "#dc322f",
"--ansi-green": "#859900",
"--ansi-yellow": "#b58900",
"--ansi-blue": "#268bd2",
"--ansi-magenta": "#d33682",
"--ansi-cyan": "#2aa198",
"--ansi-white": "#eee8d5",
"--ansi-bright-black": "#002b36",
"--ansi-bright-red": "#cb4b16",
"--ansi-bright-green": "#586e75",
"--ansi-bright-yellow": "#657b83",
"--ansi-bright-blue": "#839496",
"--ansi-bright-magenta": "#6c71c4",
"--ansi-bright-cyan": "#93a1a1",
"--ansi-bright-white": "#fdf6e3"
}
}
@@ -0,0 +1,142 @@
// Solarized palette by Ethan Schoonover (https://github.com/altercation/solarized),
// MIT licence, see LICENSE. Adapted to OpenIDEA's tokens by bghtyu.
// UI theme; the editor colour scheme is in solarized-light.scheme.json.
{
"id": "solarized-light",
"name": "Solarized Light",
"kind": "light",
"tokens": {
// Surfaces
"--bg-app": "#fdf6e3",
"--bg-elev": "#eee8d5",
"--bg-popup": "#fdf6e3",
"--bg-hover": "#eee8d5",
"--bg-selected": "#eee8d5",
"--bg-selected-strong": "#eee8d5",
"--bg-current-line": "#eee8d5",
"--bg-debug-line": "color-mix(in srgb, #268bd2 12%, #fdf6e3)",
"--bg-backdrop": "#eee8d5",
"--bg-input": "#fdf6e3",
// Lines
"--border": "#eee8d5",
"--border-popup": "#eee8d5",
"--border-strong": "#657b83",
"--border-window": "#eee8d5",
"--control-off": "#657b83",
// Text
"--fg-strong": "#657b83",
"--fg-emphasis": "#657b83",
"--fg": "#657b83",
"--fg-secondary": "#657b83",
"--fg-muted": "#657b83",
"--fg-subtle": "#657b83",
"--fg-faint": "#657b83",
"--fg-gutter": "#657b83",
"--fg-icon": "#657b83",
// Accent
"--accent": "#268bd2",
"--link": "#268bd2",
"--link-hover": "#2aa198",
// Charts
"--chart-series": "#dc322f",
// Status
"--danger": "#dc322f",
"--warning": "#b58900",
"--success": "#859900",
"--db-connected": "#859900",
"--db-connected-ring": "#859900",
"--info": "#268bd2",
// Vcs
"--vcs-modified": "#268bd2",
"--vcs-added": "#859900",
"--vcs-deleted": "#657b83",
"--vcs-untracked": "#dc322f",
"--vcs-ignored": "#b58900",
"--vcs-conflict": "#dc322f",
"--gutter-modified": "#268bd2",
"--gutter-added": "#859900",
"--gutter-deleted": "#dc322f",
// Shadows and window controls
"--shadow-popup": "0 4px 16px #00000024",
"--shadow-dialog": "0 10px 36px #00000033",
"--traffic-close": "#ff5f57",
"--traffic-close-border": "#e0443e",
"--traffic-close-pressed": "#bf4942",
"--traffic-glyph": "#4d0000",
"--traffic-off": "#eee8d5",
"--traffic-off-border": "#eee8d5",
// Additive
"--bg-tooltip": "#fdf6e3",
"--fg-tooltip": "#657b83",
"--border-tooltip": "#eee8d5",
"--bg-control": "#fdf6e3",
"--bg-control-hover": "#eee8d5",
"--bg-active": "#eee8d5",
"--bg-scrim": "color-mix(in srgb, #fdf6e3 68%, transparent)",
"--fg-on-accent": "#002b36",
"--shadow-tooltip": "0 4px 16px #00000024",
"--shadow-balloon": "0 10px 36px #00000033",
// Islands contract
"--bg-frame": "#eee8d5",
"--bg-island": "#fdf6e3",
"--bg-stripe-hover": "#eee8d5",
"--bg-stripe-selected": "#eee8d5",
"--fg-stripe-selected-active": "#002b36",
"--fg-stripe-label": "#657b83",
"--bg-tab-selected": "#eee8d5",
"--border-tab-selected": "#268bd2",
"--bg-tab-selected-inactive": "#eee8d5",
"--border-tab-selected-inactive": "#eee8d5",
"--bg-tab-hover": "#eee8d5",
"--border-editor-tabs": "#eee8d5",
"--bg-tree-selected": "#eee8d5",
"--bg-tree-selected-inactive": "#eee8d5",
"--bg-tree-hover": "#eee8d5",
"--bg-toolbar-hover": "#eee8d5",
"--bg-toolbar-pressed": "#eee8d5",
"--bg-toolbar-selected": "#eee8d5",
"--bg-toolbar-selected-hover": "#eee8d5",
"--bg-button-primary-hover": "color-mix(in srgb, var(--accent) 86%, #657b83)",
"--fg-button-primary": "#002b36",
"--bg-button": "#fdf6e3",
"--border-button": "#eee8d5",
"--bg-field": "#fdf6e3",
"--bg-checkbox": "#fdf6e3",
"--border-checkbox": "#657b83",
"--bg-toggle-on": "#859900",
"--fg-toggle-on": "#002b36",
"--bg-toggle-off": "#eee8d5",
"--fg-toggle-off": "#657b83",
"--border-toggle": "#657b83",
"--bg-toggle-knob": "#657b83",
"--focus-ring-soft": "#268bd2",
"--scrollbar-thumb": "color-mix(in srgb, #657b83 24%, transparent)",
"--scrollbar-thumb-hover": "color-mix(in srgb, #657b83 42%, transparent)",
"--bg-search-match": "color-mix(in srgb, #b58900 22%, #fdf6e3)",
"--bg-search-match-current": "color-mix(in srgb, #b58900 36%, #fdf6e3)",
"--fg-search-match": "#657b83",
"--bg-dialog": "#fdf6e3",
"--bg-popup-inline": "#eee8d5",
"--bg-popup-advertiser": "#eee8d5",
"--bg-feedback-info": "color-mix(in srgb, #268bd2 12%, #fdf6e3)",
"--border-feedback-info": "color-mix(in srgb, #268bd2 55%, #fdf6e3)",
"--bg-feedback-error": "color-mix(in srgb, #dc322f 12%, #fdf6e3)",
"--border-feedback-error": "color-mix(in srgb, #dc322f 55%, #fdf6e3)",
"--bg-feedback-warning": "color-mix(in srgb, #b58900 12%, #fdf6e3)",
"--border-feedback-warning": "color-mix(in srgb, #b58900 55%, #fdf6e3)",
"--bg-feedback-success": "color-mix(in srgb, #859900 12%, #fdf6e3)",
"--border-feedback-success": "color-mix(in srgb, #859900 55%, #fdf6e3)",
"--bg-counter": "#268bd2",
"--fg-counter": "#002b36",
"--bg-progress-track": "#eee8d5"
}
}
+25 -2
View File
@@ -3,9 +3,32 @@
// so a new window starts in it instead of flashing the light default.
// An external file because the CSP forbids inline scripts.
try {
const root = document.documentElement;
const addStyle = (id, css) => {
const style = document.createElement("style");
style.id = id;
style.textContent = css;
document.head.appendChild(style);
};
const last = JSON.parse(localStorage.getItem("openidea.theme") ?? "null");
if (last?.theme) document.documentElement.dataset.theme = last.theme;
if (last?.contrast) document.documentElement.dataset.contrast = last.contrast;
if (last?.theme) root.dataset.theme = last.theme;
if (last?.contrast) root.dataset.contrast = last.contrast;
// A plugin theme's stylesheet (plugins/bridge.ts), so the workbench doesn't
// show the built-in theme until the plugin host answers.
const plugin = JSON.parse(localStorage.getItem("openidea.pluginTheme") ?? "null");
if (typeof plugin?.id === "string" && typeof plugin.css === "string" && !/[<\\]|url\(/i.test(plugin.css)) {
addStyle("openidea-plugin-theme", plugin.css);
root.dataset.pluginTheme = plugin.id;
root.dataset.pluginThemeKind = root.dataset.theme = plugin.kind === "light" ? "light" : "dark";
delete root.dataset.contrast;
}
// The splash's frame, text and accent colours, resolved by theme.ts (splash.css).
const colors = [
["--boot-frame", last?.frame],
["--boot-fg", last?.fg],
["--boot-accent", last?.accent],
].filter(([, v]) => typeof v === "string" && /^#[0-9a-f]{3,8}$/i.test(v));
if (colors.length) addStyle("openidea-boot-colors", `:root { ${colors.map(([k, v]) => `${k}: ${v};`).join(" ")} }`);
} catch {
/* storage unavailable */
}
+10 -7
View File
@@ -1,11 +1,14 @@
/* Linked, not inlined in index.html: release builds hash inline <style> blocks into the CSP,
which voids style-src 'unsafe-inline' and blocks the editor's runtime-injected styles. */
/* Start-up splash over the page until the window's launch project is open (platform/splash.ts).
Tokens aren't loaded yet, so the colours mirror --bg-frame / --fg-muted / --accent. */
Until tokens load, the colours are the last theme's --bg-frame / --fg-muted / --accent that
boot.js restored as --boot-*, else these built-in ones. */
/* Not on html: the canvas then takes body's (themed) background, which a fast resize shows before relayout. */
body, .splash { background: #e9eaee; }
:root[data-theme="dark"] :is(body, .splash) { background: #26282c; }
:root:is([data-theme="dark-hc"], [data-theme="dark"][data-contrast="high"]) :is(body, .splash) { background: #1a1b1e; }
body, .splash { background: var(--bg-frame, var(--boot-frame, #e9eaee)); }
:root[data-theme="dark"] :is(body, .splash) { background: var(--bg-frame, var(--boot-frame, #26282c)); }
:root:is([data-theme="dark-hc"], [data-theme="dark"][data-contrast="high"]) :is(body, .splash) {
background: var(--bg-frame, var(--boot-frame, #1a1b1e));
}
.splash {
position: fixed;
inset: 0;
@@ -18,7 +21,7 @@ body, .splash { background: #e9eaee; }
padding-bottom: 38px;
box-sizing: border-box;
font: 500 15px/1.2 "Inter Variable", Inter, system-ui, -apple-system, sans-serif;
color: #6c707e;
color: var(--fg-muted, var(--boot-fg, #6c707e));
/* As on body: picking it up when the app CSS arrives would change the wordmark's weight. */
-webkit-font-smoothing: antialiased;
-webkit-user-select: none;
@@ -31,14 +34,14 @@ body, .splash { background: #e9eaee; }
will-change: opacity;
}
.splash.splash-out { opacity: 0; pointer-events: none; }
:root[data-theme^="dark"] .splash { color: #8c8f95; }
:root[data-theme^="dark"] .splash { color: var(--fg-muted, var(--boot-fg, #8c8f95)); }
.splash svg { width: 56px; height: 56px; }
.splash-bar { width: 120px; height: 2px; border-radius: 1px; overflow: hidden; background: rgb(127 127 127 / 0.18); }
/* Fake progress: eases towards ~90% over the 4 s cap; hideSplash() fills it before fading out. */
.splash-bar > i {
display: block;
height: 100%;
background: #3871e1;
background: var(--accent, var(--boot-accent, #3871e1));
transform-origin: left;
animation: splash-fill 4s cubic-bezier(0.1, 0.7, 0.3, 1) both;
}
+68 -1
View File
@@ -1,4 +1,4 @@
import { readFileSync } from "node:fs";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
convertIcls,
@@ -77,6 +77,73 @@ describe("theme plugin template", () => {
});
});
/** Theme packages in plugins/: every theme they contribute, with its editor scheme. */
function pluginThemes() {
return readdirSync("plugins", { withFileTypes: true })
.filter((d) => d.isDirectory() && existsSync(`plugins/${d.name}/plugin.toml`))
.flatMap((d) => {
const dir = `plugins/${d.name}`;
const manifest = read(`${dir}/plugin.toml`);
return manifest
.split("[[contributes.themes]]")
.slice(1)
.map((entry) => ({
dir,
manifest,
path: /^path\s*=\s*"([^"]+)"/m.exec(entry)?.[1],
scheme: /^editor-scheme\s*=\s*"([^"]+)"/m.exec(entry)?.[1],
}));
});
}
describe("theme plugins in plugins/", () => {
const themes = pluginThemes();
it("include Solarized and One, light and dark", () => {
expect(themes.map((t) => `${t.dir}/${t.path}`)).toEqual(
expect.arrayContaining([
"plugins/theme-solarized/themes/solarized-light.theme.json",
"plugins/theme-solarized/themes/solarized-dark.theme.json",
"plugins/theme-one/themes/one-light.theme.json",
"plugins/theme-one/themes/one-dark.theme.json",
]),
);
});
it.each(themes.map((t) => [`${t.dir}/${t.path}`, t] as const))(
"%s sets every UI token and its scheme every editor token",
(_name, t) => {
// A failure here means tokens.css gained (or lost) a themeable token: add it to the theme too.
expect(t.path && t.scheme, "theme and editor-scheme paths").toBeTruthy();
expect(t.manifest).not.toMatch(/^wasm\s*=/m);
const ui = JSON.parse(stripJsonComments(read(`${t.dir}/${t.path}`))) as {
id: string;
name: string;
kind: string;
tokens: Record<string, string>;
};
const ed = JSON.parse(stripJsonComments(read(`${t.dir}/${t.scheme}`))) as { tokens: Record<string, string> };
expect(ui.id).toMatch(/^[a-z0-9][a-z0-9._-]*$/);
expect(ui.name.trim()).not.toBe("");
expect(["light", "dark"]).toContain(ui.kind);
expect(sorted(Object.keys(ui.tokens))).toEqual(sorted(sets.ui));
expect(sorted(Object.keys(ed.tokens))).toEqual(sorted(sets.scheme));
for (const [k, v] of Object.entries({ ...ui.tokens, ...ed.tokens })) {
expect(/[;{}<>\\\n]|url\(|image\(/i.test(v), `${k}: ${v}`).toBe(false);
// var() may only point at tokens the IDE defines.
for (const [, ref] of v.matchAll(/var\((--[a-z0-9-]+)/g)) expect(css, `${k} → ${ref}`).toContain(`${ref}:`);
}
},
);
it("ship the licence of the palettes they port", () => {
for (const dir of new Set(themes.map((t) => t.dir))) {
if (dir === "plugins/theme-solarized" || dir === "plugins/theme-one")
expect(read(`${dir}/LICENSE`), dir).toContain("MIT License");
}
});
});
describe("IntelliJ theme import", () => {
const themeJson = `{
// comment
+1
View File
@@ -330,6 +330,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
plugin::plugin_run_command,
plugin::plugin_set_active_file,
plugin::plugin_set_theme,
plugin::plugin_set_theme_frame,
perf::app_metrics,
perf::clear_caches,
analysis::inspect_code,
+14
View File
@@ -11,9 +11,13 @@ use parking_lot::Mutex;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock};
use std::time::Duration;
use tauri::ipc::Channel;
use tauri::{AppHandle, Manager, State, Window};
/// How long `plugin_list` waits for the startup load before answering with what it has.
const INITIAL_LOAD_WAIT: Duration = Duration::from_secs(5);
/// Window label → its event channel.
static SUBSCRIBERS: LazyLock<Mutex<HashMap<String, Channel<PluginEvent>>>> = LazyLock::new(Default::default);
@@ -62,6 +66,8 @@ pub async fn plugin_list(window: Window, state: State<'_, AppState>) -> IdeResul
let host = state.plugins.clone();
let root = state.project(window.label()).ok().map(|p| p.root.clone());
blocking(move || {
// A window opened at startup would otherwise see no plugins and drop the selected theme.
host.wait_initial_load(INITIAL_LOAD_WAIT);
if let Some(root) = &root {
host.sync_project(root);
}
@@ -156,3 +162,11 @@ pub fn plugin_set_theme(
_ => state.plugins.set_theme(None),
}
}
/// The UI reports the selected plugin theme's resolved frame colour (`#rrggbb[aa]`),
/// which new windows open in before their page loads.
#[tauri::command]
#[specta::specta]
pub fn plugin_set_theme_frame(state: State<'_, AppState>, color: Option<String>) -> IdeResult<()> {
state.plugins.set_theme_frame(color.as_deref())
}
+27 -4
View File
@@ -230,11 +230,21 @@ pub fn frame_color(appearance: &AppearanceSettings, os_dark: bool) -> Color {
}
}
/// A `#rgb` / `#rrggbb` / `#rrggbbaa` colour sent by the UI (Tauri's own parser).
fn hex_color(hex: &str) -> Option<Color> {
serde_json::from_value(serde_json::Value::String(hex.to_owned())).ok()
}
/// Builds a project window painted in the saved theme's frame colour, so it
/// doesn't flash the light default before its page (and splash) loads.
fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult<WebviewWindow> {
let appearance = app.try_state::<AppState>().map(|s| s.global_settings.effective().appearance);
if let Some(a) = &appearance {
let state = app.try_state::<AppState>();
let appearance = state.as_ref().map(|s| s.global_settings.effective().appearance);
// A plugin theme replaces the built-in one in either OS mode; the UI records its resolved frame.
let plugin_frame = state.as_ref().and_then(|s| s.plugins.startup_frame()).and_then(|c| hex_color(&c));
if let Some(color) = plugin_frame {
config.background_color = Some(color);
} else if let Some(a) = &appearance {
config.background_color = Some(frame_color(a, false));
}
let window = WebviewWindowBuilder::from_config(app, &config)
@@ -242,7 +252,7 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult<
.map_err(|e| IdeError::internal(format!("cannot create window: {e}")))?;
apply_zoom(app, &window);
// "Sync with OS" needs the OS mode, known once the window exists.
if let Some(a) = appearance.filter(|a| a.sync_with_os || a.theme == "system")
if let Some(a) = appearance.filter(|a| plugin_frame.is_none() && (a.sync_with_os || a.theme == "system"))
&& window.theme().is_ok_and(|t| t == Theme::Dark)
{
let _ = window.set_background_color(Some(frame_color(&a, true)));
@@ -418,7 +428,7 @@ pub fn open_settings_window(
config.fullscreen = false;
config.drag_drop_enabled = false;
// macOS shows a child window as soon as it is attached, before the page loads: paint the theme's frame colour.
if let Some(color) = background.and_then(|c| serde_json::from_value(serde_json::Value::String(c)).ok()) {
if let Some(color) = background.as_deref().and_then(hex_color) {
config.background_color = Some(color);
}
windows.set_launch(&label, Launch { project: None, settings_page: page, new_projects });
@@ -465,6 +475,19 @@ mod tests {
assert_eq!(frame_color(&a, true), Color(0x26, 0x28, 0x2c, 0xff));
}
#[test]
fn frame_colours_from_the_ui_parse_as_hex() {
for (hex, expected) in [
("#eaeaeb", Color(0xea, 0xea, 0xeb, 0xff)),
("#21252b", Color(0x21, 0x25, 0x2b, 0xff)),
("#8040bfcc", Color(0x80, 0x40, 0xbf, 0xcc)),
("#fff", Color(0xff, 0xff, 0xff, 0xff)),
] {
assert_eq!(hex_color(hex), Some(expected), "{hex}");
}
assert_eq!(hex_color("hsl(220, 13%, 15%)"), None);
}
#[test]
fn labels_sort_main_first_then_numerically() {
assert_eq!(
+5
View File
@@ -685,6 +685,11 @@ export const commands = {
pluginId: string,
theme: ThemeDef,
} | null, IdeError>(__TAURI_INVOKE("plugin_set_theme", { pluginId, themeId })),
/**
* The UI reports the selected plugin theme's resolved frame colour (`#rrggbb[aa]`),
* which new windows open in before their page loads.
*/
pluginSetThemeFrame: (color: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("plugin_set_theme_frame", { color })),
/**
* Snapshot of process memory and subsystem sizes, summed over all projects.
* Async: the process walk runs on the blocking pool, not the main thread.
+4 -1
View File
@@ -26,7 +26,10 @@ export function installColorScheme(): () => void {
if (s.settings !== prev.settings) apply();
});
const observer = typeof MutationObserver !== "undefined" ? new MutationObserver(apply) : null;
observer?.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "data-contrast"] });
observer?.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "data-contrast", "data-plugin-theme"],
});
return () => {
unsubscribe();
observer?.disconnect();
@@ -144,7 +144,7 @@ describe("store", () => {
it("selects and clears a plugin theme", async () => {
await refreshPlugins();
await selectPluginTheme("theme-solarized-dark", "solarized-dark");
await selectPluginTheme("theme-solarized", "solarized-dark");
expect(usePlugins.getState().activeTheme?.theme.id).toBe("solarized-dark");
await selectPluginTheme(null, null);
expect(usePlugins.getState().activeTheme).toBeNull();
@@ -238,7 +238,7 @@ describe("PluginsPage", () => {
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Enable Hello" })).toBeInTheDocument());
expect(screen.getByRole("button", { name: "Run Hello: Insert Date" })).toBeDisabled();
await user.click(within(list).getByText("Solarized Dark Theme"));
await user.click(within(list).getByText("Solarized Themes"));
await user.click(screen.getByRole("button", { name: "Apply theme Solarized Dark" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Use Default Theme" })).toBeInTheDocument());
@@ -309,7 +309,7 @@ describe("catalog helpers", () => {
expect(catalogState(entry, [plugin({ id: "hello", version: "0.0.9" })])).toBe("update");
expect(catalogState(entry, [plugin({ id: "hello", version: "0.1.0" })])).toBe("installed");
expect(catalogState(entry, [plugin({ id: "hello", source: "dev" })])).toBe("dev");
expect(filterCatalog(sampleCatalog(), "solarized").map((c) => c.id)).toEqual(["theme-solarized-dark"]);
expect(filterCatalog(sampleCatalog(), "solarized").map((c) => c.id)).toEqual(["theme-solarized"]);
});
it("disables and re-enables all downloaded plugins, leaving dev plugins alone", async () => {
@@ -0,0 +1,153 @@
import { readFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ActiveTheme } from "@/bindings";
import { STARTUP_THEME_KEY, syncWindowBackground } from "@/workbench/theme";
import { createMemoryPluginsBackend, setPluginsBackend, type PluginsBackend } from "../backend";
import {
applyPluginTheme,
PLUGIN_THEME_CACHE_KEY,
PLUGIN_THEME_STYLE_ID,
trackPluginTheme,
type CachedPluginTheme,
} from "../bridge";
import { usePlugins } from "../store";
const BOOT_JS = readFileSync("public/boot.js", "utf8");
/** Runs public/boot.js as the next window would, before any app code. */
const boot = () => new Function(BOOT_JS)();
const ONE_DARK: ActiveTheme = {
pluginId: "theme-one",
theme: {
id: "one-dark",
name: "One Dark",
kind: "dark",
tokens: {
"--bg-frame": "hsl(220, 13%, 15%)",
"--fg-muted": "hsl(220, 9%, 55%)",
"--accent": "hsl(220, 100%, 66%)",
},
},
};
const SOLARIZED_LIGHT: ActiveTheme = {
pluginId: "theme-solarized",
theme: { id: "solarized-light", name: "Solarized Light", kind: "light", tokens: { "--bg-frame": "#eee8d5" } },
};
/** Computed style as a browser would resolve the active plugin theme. */
function computedColors(values: Record<string, string>) {
return vi.spyOn(globalThis, "getComputedStyle").mockImplementation(
() =>
({
getPropertyValue: (name: string) => values[name] ?? "",
color: "",
}) as unknown as CSSStyleDeclaration,
);
}
function resetRoot() {
const root = document.documentElement;
for (const key of Object.keys(root.dataset)) delete root.dataset[key];
document.getElementById(PLUGIN_THEME_STYLE_ID)?.remove();
document.getElementById("openidea-boot-colors")?.remove();
}
let backend: PluginsBackend;
beforeEach(() => {
localStorage.clear();
resetRoot();
backend = createMemoryPluginsBackend();
setPluginsBackend(backend);
usePlugins.setState({ plugins: [], activeTheme: null, loaded: false, error: null });
});
afterEach(() => {
vi.restoreAllMocks();
applyPluginTheme(null);
setPluginsBackend(null);
localStorage.clear();
resetRoot();
});
describe("plugin theme at start-up", () => {
it("restores the last plugin theme and splash colours before the app loads", () => {
const style = computedColors({ "--bg-frame": "#21252b", "--fg-muted": "#828997", "--accent": "#5c8aff" });
const stop = trackPluginTheme();
usePlugins.setState({ loaded: true, activeTheme: ONE_DARK });
syncWindowBackground();
stop();
style.mockRestore();
resetRoot();
boot();
const root = document.documentElement;
expect(root.dataset.pluginTheme).toBe("theme-one/one-dark");
expect(root.dataset.pluginThemeKind).toBe("dark");
expect(root.dataset.theme).toBe("dark");
expect(document.getElementById(PLUGIN_THEME_STYLE_ID)?.textContent).toContain("--bg-frame: hsl(220, 13%, 15%);");
expect(document.getElementById("openidea-boot-colors")?.textContent).toBe(
":root { --boot-frame: #21252b; --boot-fg: #828997; --boot-accent: #5c8aff; }",
);
});
it("keeps the restored theme until the plugin list arrives, then follows it", () => {
const cached: CachedPluginTheme = {
id: "theme-one/one-dark",
kind: "dark",
css: ':root[data-plugin-theme]:not([data-plugin-theme=""]) {\n --bg-frame: #21252b;\n}\n',
};
localStorage.setItem(PLUGIN_THEME_CACHE_KEY, JSON.stringify(cached));
boot();
const root = document.documentElement;
const stop = trackPluginTheme();
// Nothing listed yet: the boot theme stays.
expect(root.dataset.pluginTheme).toBe("theme-one/one-dark");
expect(document.getElementById(PLUGIN_THEME_STYLE_ID)).not.toBeNull();
// A failed listing doesn't drop it either.
usePlugins.setState({ loaded: true, error: "backend unavailable" });
expect(root.dataset.pluginTheme).toBe("theme-one/one-dark");
usePlugins.setState({ error: null, activeTheme: SOLARIZED_LIGHT });
expect(root.dataset.pluginTheme).toBe("theme-solarized/solarized-light");
expect(root.dataset.theme).toBe("light");
expect(JSON.parse(localStorage.getItem(PLUGIN_THEME_CACHE_KEY)!)).toMatchObject({
id: "theme-solarized/solarized-light",
kind: "light",
});
// The plugin was disabled or uninstalled meanwhile.
usePlugins.setState({ activeTheme: null });
expect(root.dataset.pluginTheme).toBeUndefined();
expect(document.getElementById(PLUGIN_THEME_STYLE_ID)).toBeNull();
expect(localStorage.getItem(PLUGIN_THEME_CACHE_KEY)).toBeNull();
stop();
});
it("ignores a cached stylesheet that could load resources", () => {
localStorage.setItem(
PLUGIN_THEME_CACHE_KEY,
JSON.stringify({ id: "p/t", kind: "dark", css: ":root { --bg-app: url(https://example.com/x) }" }),
);
localStorage.setItem(STARTUP_THEME_KEY, JSON.stringify({ theme: "light", frame: "red; color: blue" }));
boot();
expect(document.documentElement.dataset.pluginTheme).toBeUndefined();
expect(document.documentElement.dataset.theme).toBe("light");
expect(document.getElementById(PLUGIN_THEME_STYLE_ID)).toBeNull();
expect(document.getElementById("openidea-boot-colors")).toBeNull();
});
it("reports the resolved frame colour once per selected theme", () => {
const setFrame = vi.spyOn(backend, "setThemeFrame");
computedColors({ "--bg-frame": "rgb(33, 37, 43)" });
const stop = trackPluginTheme();
usePlugins.setState({ loaded: true, activeTheme: ONE_DARK });
usePlugins.setState({ activeTheme: { ...ONE_DARK } });
expect(setFrame.mock.calls).toEqual([["#21252b"]]);
// Selecting it again after the default theme reports it again: the backend forgot it.
usePlugins.setState({ activeTheme: null });
usePlugins.setState({ activeTheme: ONE_DARK });
expect(setFrame).toHaveBeenCalledTimes(2);
stop();
});
});
+45 -21
View File
@@ -18,6 +18,8 @@ export interface PluginsBackend {
runCommand(pluginId: string, commandId: string, arg: string | null): Promise<string | null>;
setActiveFile(path: string | null): Promise<void>;
setTheme(pluginId: string | null, themeId: string | null): Promise<ActiveTheme | null>;
/** The selected theme's resolved frame colour (`#rrggbb[aa]`), which new windows open in. */
setThemeFrame(color: string | null): Promise<void>;
/** Folder picker; `null` when cancelled. */
pickFolder(): Promise<string | null>;
/** Subscribes to backend plugin events (this window only). */
@@ -40,6 +42,9 @@ export const tauriPluginsBackend: PluginsBackend = {
await commands.pluginSetActiveFile(path);
},
setTheme: (pluginId, themeId) => call(commands.pluginSetTheme(pluginId, themeId)),
setThemeFrame: async (color) => {
await call(commands.pluginSetThemeFrame(color));
},
pickFolder: async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({ directory: true, multiple: false, title: "Install Plugin from Disk" });
@@ -71,21 +76,38 @@ function ensureSubscribed() {
// ---------------------------------------------------------------------------
// Browser simulation: the bundled sample plugins, with `hello` implemented in JS.
const SOLARIZED: ActiveTheme["theme"] = {
id: "solarized-dark",
name: "Solarized Dark",
kind: "dark",
tokens: {
"--bg-app": "#002b36",
"--bg-elev": "#03313c",
"--bg-popup": "#073642",
"--fg": "#93a1a1",
"--fg-strong": "#eee8d5",
"--accent": "#268bd2",
"--syn-keyword": "#859900",
"--syn-string": "#2aa198",
const SOLARIZED: ActiveTheme["theme"][] = [
{
id: "solarized-light",
name: "Solarized Light",
kind: "light",
tokens: {
"--bg-app": "#fdf6e3",
"--bg-island": "#fdf6e3",
"--bg-frame": "#eee8d5",
"--bg-elev": "#eee8d5",
"--fg": "#657b83",
"--accent": "#268bd2",
"--syn-keyword": "#859900",
"--syn-string": "#2aa198",
},
},
};
{
id: "solarized-dark",
name: "Solarized Dark",
kind: "dark",
tokens: {
"--bg-app": "#002b36",
"--bg-island": "#002b36",
"--bg-frame": "#073642",
"--bg-elev": "#073642",
"--fg": "#839496",
"--accent": "#268bd2",
"--syn-keyword": "#859900",
"--syn-string": "#2aa198",
},
},
];
function samplePlugins(): PluginInfo[] {
const base = {
@@ -118,14 +140,14 @@ function samplePlugins(): PluginInfo[] {
},
{
...base,
id: "theme-solarized-dark",
name: "Solarized Dark Theme",
id: "theme-solarized",
name: "Solarized Themes",
version: "1.0.0",
description: "Ethan Schoonover's Solarized palette, dark variant.",
path: "~/Library/Application Support/ai.l0ng.openidea/plugins/theme-solarized-dark",
description: "Solarized Light and Solarized Dark: Ethan Schoonover's Solarized palette.",
path: "~/Library/Application Support/ai.l0ng.openidea/plugins/theme-solarized",
status: "static",
hasCode: false,
themes: [{ id: "solarized-dark", name: "Solarized Dark", kind: "dark" }],
themes: SOLARIZED.map(({ id, name, kind }) => ({ id, name, kind })),
},
{
...base,
@@ -179,8 +201,9 @@ export function createMemoryPluginsBackend(
const activeTheme = (): ActiveTheme | null => {
if (!theme) return null;
const p = plugins.find((x) => x.id === theme?.pluginId);
if (!p?.enabled || theme.themeId !== SOLARIZED.id) return null;
return { pluginId: p.id, theme: SOLARIZED };
const t = SOLARIZED.find((x) => x.id === theme?.themeId);
if (!p?.enabled || !t) return null;
return { pluginId: p.id, theme: t };
};
return {
list: async () => ({ plugins: plugins.map((p) => ({ ...p })), activeTheme: activeTheme() }),
@@ -237,6 +260,7 @@ export function createMemoryPluginsBackend(
emit({ type: "themeChanged", theme: t });
return t;
},
setThemeFrame: async () => {},
pickFolder: async () => null,
onEvent: (handler) => {
listeners.add(handler);
+60 -3
View File
@@ -8,7 +8,7 @@ import type { ActiveTheme, PluginEvent, PluginInfo } from "@/bindings";
import { activeEditor, useEditorRegistry } from "@/editor/api";
import { registerAction, type Action } from "@/platform/actions";
import { notifications } from "@/workbench/contributions";
import { applyThemeAttributes, currentThemeId, syncWindowBackground } from "@/workbench/theme";
import { applyThemeAttributes, currentThemeId, syncWindowBackground, windowBackgroundHex } from "@/workbench/theme";
import { pluginsBackend } from "./backend";
import { refreshPlugins, runPluginCommand, usePlugins } from "./store";
@@ -105,10 +105,67 @@ export function applyPluginTheme(theme: ActiveTheme | null, root: HTMLElement =
if (had || theme) syncWindowBackground();
}
/** localStorage key read by public/boot.js: the selected theme's stylesheet, applied before the app loads. */
export const PLUGIN_THEME_CACHE_KEY = "openidea.pluginTheme";
export interface CachedPluginTheme {
/** `data-plugin-theme`: `<pluginId>/<themeId>`. */
id: string;
kind: "light" | "dark";
css: string;
}
function cachePluginTheme(theme: ActiveTheme | null) {
try {
if (!theme) {
localStorage.removeItem(PLUGIN_THEME_CACHE_KEY);
return;
}
const cached: CachedPluginTheme = {
id: `${theme.pluginId}/${theme.theme.id}`,
kind: theme.theme.kind === "light" ? "light" : "dark",
css: pluginThemeCss(theme.theme),
};
localStorage.setItem(PLUGIN_THEME_CACHE_KEY, JSON.stringify(cached));
} catch {
/* storage unavailable */
}
}
let reportedFrame: string | undefined;
/**
* Tells the backend the theme's resolved frame colour, which new windows open
* in. Selecting a theme clears it there, so it is sent once per selection.
*/
function reportThemeFrame(theme: ActiveTheme | null) {
const frame = theme ? windowBackgroundHex() : null;
if (!theme || !frame) {
reportedFrame = undefined;
return;
}
const key = `${theme.pluginId}/${theme.theme.id}|${frame}`;
if (key === reportedFrame) return;
reportedFrame = key;
void pluginsBackend()
.setThemeFrame(frame)
.catch(() => (reportedFrame = undefined));
}
/**
* Applies the selected plugin theme and keeps it for the next start-up. Until
* the first plugin list arrives, the theme public/boot.js restored stays.
*/
export function trackPluginTheme(): () => void {
applyPluginTheme(usePlugins.getState().activeTheme);
const sync = (s: ReturnType<typeof usePlugins.getState>) => {
if (!s.activeTheme && (!s.loaded || s.error)) return;
applyPluginTheme(s.activeTheme);
cachePluginTheme(s.activeTheme);
reportThemeFrame(s.activeTheme);
};
sync(usePlugins.getState());
const unsubscribe = usePlugins.subscribe((s, prev) => {
if (s.activeTheme !== prev.activeTheme) applyPluginTheme(s.activeTheme);
if (s.activeTheme !== prev.activeTheme || s.loaded !== prev.loaded) sync(s);
});
return () => {
unsubscribe();
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import type { Settings } from "@/platform/ipc";
import { commands, type Settings } from "@/platform/ipc";
const settingsSet = vi.fn();
const settingsGet = vi.fn();
@@ -282,3 +282,29 @@ describe("Settings dialog", () => {
expect(within(dialog).getByLabelText("UI font size")).toHaveValue("13");
});
});
describe("native Settings window background", () => {
it.each([
["a hex frame", "#26282c", "", "#26282c"],
["an hsl() frame", "hsl(220, 13%, 15%)", "rgb(33, 37, 43)", "#21252b"],
[
"a translucent color-mix() frame",
"color-mix(in srgb, #8040bf 80%, transparent)",
"color(srgb 0.5 0.25 0.75 / 0.8)",
"#8040bfcc",
],
])("sends %s as hex, the colour format the window command parses", async (_name, token, resolved, hex) => {
const native = vi.spyOn(commands, "windowOpenSettings").mockResolvedValue({ status: "ok", data: null });
const computed = vi
.spyOn(globalThis, "getComputedStyle")
.mockReturnValue({ getPropertyValue: () => token, color: resolved } as unknown as CSSStyleDeclaration);
try {
await executeAction("file.settings", "appearance");
expect(native).toHaveBeenCalledWith("appearance", false, hex);
expect(useSettingsDialog.getState().open).toBe(false);
} finally {
computed.mockRestore();
native.mockRestore();
}
});
});
+2 -2
View File
@@ -17,6 +17,7 @@ import { call, commands, isTauri } from "@/platform/ipc";
import { isPopupOpen, lazyComponent } from "@/ui";
import { loadSettings, useProject } from "@/platform/project";
import { provideSettingsSearchIndex, registerOverlay } from "@/workbench/contributions";
import { windowBackgroundHex } from "@/workbench/theme";
import { LazySettingsDialog, loadBuiltinPages, unloadBuiltinPages } from "./dialog/LazySettingsDialog";
import {
closeSettings,
@@ -85,8 +86,7 @@ function settingsActions(open: (page?: string, newProjects?: boolean) => void):
async function showSettings(page?: string, newProjects = false) {
if (isTauri()) {
try {
const frame = getComputedStyle(document.documentElement).getPropertyValue("--bg-frame").trim();
await call(commands.windowOpenSettings(page ?? null, newProjects, frame || null));
await call(commands.windowOpenSettings(page ?? null, newProjects, windowBackgroundHex()));
return;
} catch (e) {
console.warn("settings window unavailable, using the dialog", e);
+5 -1
View File
@@ -195,7 +195,11 @@ export function activate(): () => void {
terminalRuntime()?.applyThemeToAll();
terminalRuntime()?.applyThemeToEmbedded();
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "style"] });
// A plugin theme switch keeps data-theme when both themes share a light / dark base.
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "data-contrast", "data-plugin-theme", "style"],
});
disposers.push(() => observer.disconnect());
}
+2 -29
View File
@@ -7,36 +7,9 @@
import type { ITheme, Terminal } from "@xterm/xterm";
import { fontWeightOf } from "@/platform/editorSettings";
import { BUNDLED_MONO_FAMILY, fontsLoaded, monoFontFamily, monoStack } from "@/platform/fonts";
import { parseColor, type Rgba } from "@/platform/cssColor";
export type Rgba = [number, number, number, number];
/** Parses `#rgb[a]`, `#rrggbb[aa]`, `rgb[a](…)` and `color(srgb …)`; `null` when unknown. */
export function parseColor(input: string): Rgba | null {
const s = input.trim().toLowerCase();
if (s.startsWith("#")) {
const h = s.slice(1);
if (!/^[0-9a-f]+$/.test(h)) return null;
if (h.length === 3 || h.length === 4) {
const [r, g, b, a = "f"] = h.split("") as [string, string, string, string?];
return [parseInt(r + r, 16), parseInt(g + g, 16), parseInt(b + b, 16), parseInt(a + a, 16) / 255];
}
if (h.length === 6 || h.length === 8) {
const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), a];
}
return null;
}
let m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/.exec(s);
if (m) {
const alpha = m[4] === undefined ? 1 : m[4].endsWith("%") ? parseFloat(m[4]) / 100 : parseFloat(m[4]);
return [Number(m[1]), Number(m[2]), Number(m[3]), alpha];
}
m = /^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+))?\s*\)$/.exec(s);
if (m) {
return [Number(m[1]) * 255, Number(m[2]) * 255, Number(m[3]) * 255, m[4] === undefined ? 1 : Number(m[4])];
}
return null;
}
export { parseColor, type Rgba };
export function toHex([r, g, b]: Rgba): string {
const h = (n: number) =>
+61 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderMarkdown } from "./render";
import { hasMermaid, renderMermaidIn, resetMermaidForTests } from "./mermaid";
import { hasMermaid, renderMermaidIn, resetMermaidForTests, subscribeTheme, themeKey } from "./mermaid";
const m = vi.hoisted(() => ({
loads: 0,
@@ -108,6 +108,66 @@ describe("mermaid in the markdown preview", () => {
expect(ids.size).toBe(2);
});
it("re-renders when switching between plugin themes of the same light / dark base", async () => {
const root = document.documentElement;
const changed = vi.fn();
const off = subscribeTheme(changed);
try {
root.dataset.theme = "dark";
root.dataset.pluginTheme = "theme-one/one-dark";
const firstKey = themeKey();
await renderMermaidIn(preview(SEQ)).done;
root.dataset.pluginTheme = "theme-solarized/solarized-dark";
await Promise.resolve();
expect(changed).toHaveBeenCalled();
expect(themeKey()).not.toBe(firstKey);
await renderMermaidIn(preview(SEQ)).done;
expect(m.render).toHaveBeenCalledTimes(2);
} finally {
off();
delete root.dataset.pluginTheme;
}
});
it("passes colours a theme writes as hsl() or color-mix() to mermaid as RGB", async () => {
const values: Record<string, string> = {
"--bg-editor": "hsl(220, 13%, 18%)",
"--fg": "color-mix(in srgb, hsl(220, 14%, 71%) 90%, black)",
"--danger": "#e06c75",
};
const resolved: Record<string, string> = {
"--bg-editor": "rgb(40, 44, 52)",
"--fg": "color(srgb 0.6 0.62 0.66)",
};
let probe = "";
const computed = vi
.spyOn(globalThis, "getComputedStyle")
.mockImplementation(
(el) =>
(el === document.documentElement
? { getPropertyValue: (n: string) => values[n] ?? "" }
: {
color: resolved[(probe = (el as HTMLElement).style.color.slice(4, -1))] ?? "",
}) as unknown as CSSStyleDeclaration,
);
try {
await renderMermaidIn(preview(SEQ)).done;
expect(probe).not.toBe("");
expect(m.initialize).toHaveBeenCalledWith(
expect.objectContaining({
themeVariables: expect.objectContaining({
background: "rgb(40, 44, 52)",
textColor: "rgb(153, 158, 168)",
errorTextColor: "#e06c75",
}),
}),
);
expect(document.querySelector("span[hidden]")).toBeNull();
} finally {
computed.mockRestore();
}
});
it("skips superseded renders", async () => {
const root = preview(SEQ.replace("test", "stale"));
const pass = renderMermaidIn(root);
+13 -5
View File
@@ -4,6 +4,7 @@
* turns placeholders into SVG. The library is imported on first use only.
*/
import type { Mermaid } from "mermaid";
import { resolveTokenColor, toRgb } from "@/platform/cssColor";
export const MERMAID_SELECTOR = "[data-mermaid]";
const OUTPUT_CLASS = "mermaid-output";
@@ -29,27 +30,34 @@ function loadMermaid(): Promise<Mermaid> {
/** Theme identity: anything that changes the colours read below. */
export function themeKey(): string {
const root = document.documentElement;
return `${root.dataset.theme ?? ""}|${root.dataset.contrast ?? ""}|${root.style.getPropertyValue("--accent")}`;
const { theme = "", contrast = "", pluginTheme = "" } = root.dataset;
return `${theme}|${contrast}|${pluginTheme}|${root.style.getPropertyValue("--accent")}`;
}
/** Calls `cb` when the theme attributes or the accent on the root change. */
/** Calls `cb` when the theme attributes (a plugin theme's included) or the accent on the root change. */
export function subscribeTheme(cb: () => void): () => void {
if (typeof MutationObserver === "undefined") return () => {};
const observer = new MutationObserver(cb);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "data-contrast", "style"],
attributeFilter: ["data-theme", "data-contrast", "data-plugin-theme", "style"],
});
return () => observer.disconnect();
}
/** Diagram colours from the IDE tokens; values mermaid can't parse (e.g. `color-mix`) are skipped. */
/**
* Diagram colours from the IDE tokens. Mermaid parses only hex and `rgb()`, so
* tokens a theme writes as `hsl()`, `color-mix()` or `var()` are resolved to RGB.
*/
function themeVariables(): Record<string, string | boolean> {
const css = getComputedStyle(document.documentElement);
const root = document.documentElement;
const css = getComputedStyle(root);
const token = (...names: string[]) => {
for (const n of names) {
const v = css.getPropertyValue(n).trim();
if (/^(#[0-9a-f]{3,8}|rgba?\([^)]*\))$/i.test(v)) return v;
const resolved = v ? resolveTokenColor(n, root) : null;
if (resolved) return toRgb(resolved);
}
return undefined;
};
+34
View File
@@ -32,6 +32,40 @@ describe("color schemes", () => {
expect(pythonItalic).toContain("--syn-python-static-italic: italic !important");
});
it("applies an explicit scheme over a plugin theme of the same base", () => {
const root = document.documentElement;
root.dataset.theme = "dark";
root.dataset.pluginTheme = "theme-one/one-dark";
try {
// "Theme default" keeps the plugin theme's own editor colours.
expect(schemeCss("theme-default", undefined, root)).toBe("");
expect(schemeCss("dark", undefined, root)).toContain("--syn-keyword: #cf8e6d !important");
delete root.dataset.pluginTheme;
expect(schemeCss("dark", undefined, root)).toBe("");
} finally {
delete root.dataset.pluginTheme;
}
});
it("re-applies the scheme when a plugin theme of the same base is selected", async () => {
const root = document.documentElement;
root.dataset.theme = "dark";
const previous = useProject.getState().settings;
useProject.setState({ settings: { editor: { colorScheme: "dark" } } as unknown as Settings });
const dispose = installColorScheme();
const sheet = () => document.getElementById("openidea-editor-color-scheme")?.textContent ?? "";
try {
expect(sheet()).toBe("");
root.dataset.pluginTheme = "theme-one/one-dark";
await Promise.resolve();
expect(sheet()).toContain("--syn-keyword: #cf8e6d !important");
} finally {
dispose();
delete root.dataset.pluginTheme;
useProject.setState({ settings: previous });
}
});
it("keeps editor weights out of the schemes", () => {
for (const id of ["light", "dark", "dark-hc"] as const) {
expect(schemeTokens(id).has("--fw-code")).toBe(false);
+4 -1
View File
@@ -267,7 +267,10 @@ const SAFE = /^[^;{}<>]*$/;
export function schemeCss(setting: string | undefined, overrides: Record<string, Record<string, TextAttributesOverride>> | undefined, root?: HTMLElement | null): string {
const id = effectiveScheme(setting, root);
const decls: Array<[string, string]> = [];
if (id !== themeScheme(root)) {
// 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);
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.
+37
View File
@@ -0,0 +1,37 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { parseColor, resolveTokenColor, toHex, toRgb } from "./cssColor";
afterEach(() => vi.restoreAllMocks());
describe("css colours", () => {
it("parses hex, rgb() and color(srgb) and formats them back", () => {
expect(parseColor("#eee8d5")).toEqual([238, 232, 213, 1]);
expect(parseColor("#fff8")).toEqual([255, 255, 255, 0x88 / 255]);
expect(parseColor("rgb(33, 37, 43)")).toEqual([33, 37, 43, 1]);
expect(parseColor("rgba(0 0 0 / 50%)")).toEqual([0, 0, 0, 0.5]);
expect(parseColor("color(srgb 0.5 0.25 0.75 / 0.8)")).toEqual([127.5, 63.75, 191.25, 0.8]);
expect(parseColor("hsl(220, 13%, 15%)")).toBeNull();
expect(toHex([33, 37, 43, 1])).toBe("#21252b");
expect(toHex([127.5, 63.75, 191.25, 0.8])).toBe("#8040bfcc");
expect(toRgb([33, 37, 43, 1])).toBe("rgb(33, 37, 43)");
expect(toRgb([0, 0, 0, 0.5])).toBe("rgba(0, 0, 0, 0.5)");
});
it("resolves tokens written as hsl(), color-mix() or var() through the browser", () => {
const root = document.documentElement;
const tokens: Record<string, string> = { "--hex": "#073642", "--hsl": "hsl(220, 13%, 15%)", "--none": "" };
vi.spyOn(globalThis, "getComputedStyle").mockImplementation(
(el) =>
(el === root
? { getPropertyValue: (n: string) => tokens[n] ?? "" }
: {
color: (el as HTMLElement).style.color === "var(--hsl)" ? "rgb(33, 37, 43)" : "",
}) as unknown as CSSStyleDeclaration,
);
expect(resolveTokenColor("--hex", root)).toEqual([7, 54, 66, 1]);
expect(resolveTokenColor("--hsl", root)).toEqual([33, 37, 43, 1]);
expect(resolveTokenColor("--none", root)).toBeNull();
// The probe element is removed again.
expect(root.querySelector("span[hidden]")).toBeNull();
});
});
+67
View File
@@ -0,0 +1,67 @@
/**
* Design tokens as concrete colours. Themes may write tokens as `hsl()`,
* `color-mix()` or `var()`; consumers outside CSS (the native window, Mermaid,
* xterm) need RGB, so a token is resolved by the browser and parsed here.
*/
/** Red, green, blue (0–255) and alpha (0–1). */
export type Rgba = [number, number, number, number];
/** Parses `#rgb[a]`, `#rrggbb[aa]`, `rgb[a](…)` and `color(srgb …)`; `null` when unknown. */
export function parseColor(input: string): Rgba | null {
const s = input.trim().toLowerCase();
if (s.startsWith("#")) {
const h = s.slice(1);
if (!/^[0-9a-f]+$/.test(h)) return null;
if (h.length === 3 || h.length === 4) {
const [r, g, b, a = "f"] = h.split("") as [string, string, string, string?];
return [parseInt(r + r, 16), parseInt(g + g, 16), parseInt(b + b, 16), parseInt(a + a, 16) / 255];
}
if (h.length === 6 || h.length === 8) {
const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), a];
}
return null;
}
let m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/.exec(s);
if (m) {
const alpha = m[4] === undefined ? 1 : m[4].endsWith("%") ? parseFloat(m[4]) / 100 : parseFloat(m[4]);
return [Number(m[1]), Number(m[2]), Number(m[3]), alpha];
}
m = /^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+))?\s*\)$/.exec(s);
if (m) {
return [Number(m[1]) * 255, Number(m[2]) * 255, Number(m[3]) * 255, m[4] === undefined ? 1 : Number(m[4])];
}
return null;
}
/** The computed colour of token `name` on `root`, whatever syntax the theme wrote it in. */
export function resolveTokenColor(name: string, root: HTMLElement = document.documentElement): Rgba | null {
if (typeof getComputedStyle === "undefined") return null;
const raw = getComputedStyle(root).getPropertyValue(name).trim();
if (!raw) return null;
const direct = parseColor(raw);
if (direct) return direct;
const probe = root.ownerDocument.createElement("span");
probe.hidden = true;
probe.style.color = `var(${name})`;
root.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return parseColor(color);
}
const byte = (v: number) => Math.min(255, Math.max(0, Math.round(v)));
/** `#rrggbb`, or `#rrggbbaa` when translucent. */
export function toHex([r, g, b, a]: Rgba): string {
const parts = [r, g, b].map(byte);
if (byte(a * 255) !== 255) parts.push(byte(a * 255));
return `#${parts.map((x) => x.toString(16).padStart(2, "0")).join("")}`;
}
/** `rgb(r, g, b)`, or `rgba(r, g, b, a)` when translucent. */
export function toRgb([r, g, b, a]: Rgba): string {
const [rr, gg, bb] = [r, g, b].map(byte);
return a < 1 ? `rgba(${rr}, ${gg}, ${bb}, ${Math.round(a * 1000) / 1000})` : `rgb(${rr}, ${gg}, ${bb})`;
}
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { rememberStartupTheme, STARTUP_THEME_KEY, windowBackgroundColor, windowBackgroundHex } from "./theme";
const mockFrame = (token: string, resolved = "") =>
vi
.spyOn(globalThis, "getComputedStyle")
.mockReturnValue({ getPropertyValue: () => token, color: resolved } as unknown as CSSStyleDeclaration);
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
});
describe("window background", () => {
it("resolves the frame to Tauri's RGBA tuple instead of passing CSS hsl() or mixes", () => {
mockFrame("#26282c");
expect(windowBackgroundColor()).toEqual([38, 40, 44, 255]);
mockFrame("hsl(230, 1%, 92%)", "rgb(234, 234, 235)");
expect(windowBackgroundColor()).toEqual([234, 234, 235, 255]);
expect(windowBackgroundHex()).toBe("#eaeaeb");
mockFrame("color-mix(in srgb, #8040bf 80%, transparent)", "color(srgb 0.5 0.25 0.75 / 0.8)");
expect(windowBackgroundColor()).toEqual([128, 64, 191, 204]);
expect(windowBackgroundHex()).toBe("#8040bfcc");
mockFrame("", "");
expect(windowBackgroundColor()).toBeNull();
expect(windowBackgroundHex()).toBeNull();
});
it("remembers the base and the resolved splash colours for the next window", () => {
const root = document.documentElement;
root.dataset.theme = "light";
const values: Record<string, string> = { "--bg-frame": "#eee8d5", "--fg-muted": "#657b83", "--accent": "#268bd2" };
vi.spyOn(globalThis, "getComputedStyle").mockReturnValue({
getPropertyValue: (n: string) => values[n] ?? "",
} as unknown as CSSStyleDeclaration);
rememberStartupTheme(root);
expect(JSON.parse(localStorage.getItem(STARTUP_THEME_KEY)!)).toEqual({
theme: "light",
frame: "#eee8d5",
fg: "#657b83",
accent: "#268bd2",
});
});
});
+54 -9
View File
@@ -4,6 +4,7 @@ import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { create } from "zustand";
import { call, commands, isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { resolveTokenColor, toHex } from "@/platform/cssColor";
export type ThemeSetting = "dark" | "light" | "system" | "dark-hc";
/** PyCharm's brand blue: the accent both Islands themes ship with. */
@@ -64,7 +65,6 @@ export function useApplyTheme() {
const id = override
? resolveThemeId({ theme: override }, systemLight)
: resolveThemeId(appearance as AppearanceLike | undefined, systemLight);
const theme = id === "light" ? "light" : "dark";
const accent = appearance?.accent;
// Until settings arrive, keep the theme public/boot.js restored rather than the light default.
@@ -77,13 +77,8 @@ export function useApplyTheme() {
const custom = !!accent && ACCENT_RE.test(accent) && !THEME_ACCENTS.has(accent.toLowerCase());
if (custom) root.style.setProperty("--accent", accent);
else root.style.removeProperty("--accent");
try {
localStorage.setItem("openidea.theme", JSON.stringify({ theme, contrast: root.dataset.contrast }));
} catch {
/* storage unavailable */
}
syncWindowBackground();
}, [pending, theme, id, accent]);
}, [pending, id, accent]);
}
/**
@@ -102,14 +97,64 @@ export function applyThemeAttributes(id: "dark" | "light" | "dark-hc", root: HTM
else delete root.dataset.contrast;
}
/**
* The frame colour as Tauri's `[r, g, b, a]` (0–255). Tauri parses neither
* `hsl()` nor `color-mix()`, which themes may use, so it is resolved here.
*/
export function windowBackgroundColor(
root: HTMLElement = document.documentElement,
): [number, number, number, number] | null {
const c = resolveTokenColor("--bg-frame", root);
if (!c) return null;
const [r, g, b, a] = c;
return [Math.round(r), Math.round(g), Math.round(b), Math.round(a * 255)];
}
/** The frame colour as `#rrggbb[aa]`, for native window commands that take a string. */
export function windowBackgroundHex(root: HTMLElement = document.documentElement): string | null {
const c = resolveTokenColor("--bg-frame", root);
return c ? toHex(c) : null;
}
/** localStorage key read by public/boot.js. */
export const STARTUP_THEME_KEY = "openidea.theme";
/**
* Remembers the theme for public/boot.js, which applies it to the next window
* before any stylesheet loads: the light / dark base, and the resolved frame,
* text and accent colours its splash is painted in.
*/
export function rememberStartupTheme(root: HTMLElement = document.documentElement) {
const hex = (name: string) => {
const c = resolveTokenColor(name, root);
return c ? toHex(c) : undefined;
};
try {
localStorage.setItem(
STARTUP_THEME_KEY,
JSON.stringify({
theme: root.dataset.theme,
contrast: root.dataset.contrast,
frame: hex("--bg-frame"),
fg: hex("--fg-muted"),
accent: hex("--accent"),
}),
);
} catch {
/* storage unavailable */
}
}
/**
* Where a live resize outruns the page, the native window and the webview (WebView2's default
* background) show their own colours; match both to the frame. The webview one is only set at
* window creation otherwise, so a theme switched later left it light.
* window creation otherwise, so a theme switched later left it light. Also remembers the theme
* for the next window's start-up.
*/
export function syncWindowBackground() {
rememberStartupTheme();
if (!isTauri()) return;
const color = getComputedStyle(document.documentElement).getPropertyValue("--bg-frame").trim();
const color = windowBackgroundColor();
if (!color) return;
getCurrentWebviewWindow()
.setBackgroundColor(color)