дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(themes): Built-in Solarized and One light/dark themes (#85)
Adds Solarized Light, Solarized Dark, One Light and One Dark as built-in themes across the workbench, editor, terminal and Mermaid, keeping the original palettes (MIT notices in THIRD_PARTY_LICENSES.md, mapping in docs/themes.md). - Native window colours are read from tokens.css at build time (`theme_frame.rs`) instead of a second colour table. - Startup restores the built-in palette before first paint. - Editor schemes, the terminal and Mermaid refresh on switches between themes of the same kind. - The plugin packages of the same themes are removed. - A test requires every built-in palette theme to define each token (only listed ones may inherit from the base theme), so new tokens can't silently miss them. Co-authored-by: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Цей коміт міститься в:
1 джерело
50308a956d
коміт
3b608355a3
48 files changed
+1267
-1351
No files matched your search
@@ -9,6 +9,7 @@ mod logging;
|
||||
mod menu;
|
||||
mod shell_env;
|
||||
mod state;
|
||||
mod theme_frame;
|
||||
mod windows;
|
||||
|
||||
use state::AppState;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Built-in themes' window frame colour (`--bg-frame`), read from the UI's own
|
||||
//! `src/styles/tokens.css` so a native window opens in the colour its page paints.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use tauri::utils::config::Color;
|
||||
|
||||
const TOKENS_CSS: &str = include_str!("../../src/styles/tokens.css");
|
||||
|
||||
/// A top-level rule: its selector list and custom-property declarations.
|
||||
type Block = (Vec<String>, HashMap<String, String>);
|
||||
|
||||
/// The rule blocks of tokens.css, in source order.
|
||||
static BLOCKS: LazyLock<Vec<Block>> = LazyLock::new(|| blocks(TOKENS_CSS));
|
||||
|
||||
/// `--bg-frame` of built-in theme `id` (`light`, `dark`, `dark-hc` or a palette
|
||||
/// theme with a `:root[data-color-theme="<id>"]` block); `None` for any other id.
|
||||
pub fn builtin_frame(id: &str) -> Option<Color> {
|
||||
builtin_frame_in(&BLOCKS, id)
|
||||
}
|
||||
|
||||
fn builtin_frame_in(blocks: &[Block], id: &str) -> Option<Color> {
|
||||
let palette = format!(":root[data-color-theme=\"{id}\"]");
|
||||
let mut selectors = vec![":root".to_owned()];
|
||||
match id {
|
||||
"dark" => {}
|
||||
"light" => selectors.push(":root[data-theme=\"light\"]".into()),
|
||||
"dark-hc" => selectors.push(":root[data-theme=\"dark-hc\"]".into()),
|
||||
_ if blocks.iter().any(|(sel, _)| sel.contains(&palette)) => {
|
||||
if id.ends_with("light") {
|
||||
selectors.push(":root[data-theme=\"light\"]".into());
|
||||
}
|
||||
selectors.push(":root[data-color-theme]".into());
|
||||
selectors.push(palette);
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
// Later matching blocks win, as in the page (the selectors involved share a specificity order).
|
||||
let mut vars: HashMap<&str, &str> = HashMap::new();
|
||||
for (sel, decls) in blocks {
|
||||
if sel.iter().any(|s| selectors.contains(s)) {
|
||||
vars.extend(decls.iter().map(|(k, v)| (k.as_str(), v.as_str())));
|
||||
}
|
||||
}
|
||||
let mut value = *vars.get("--bg-frame")?;
|
||||
for _ in 0..8 {
|
||||
match value.strip_prefix("var(").and_then(|v| v.strip_suffix(')')) {
|
||||
Some(name) => value = vars.get(name.split(',').next()?.trim())?,
|
||||
None => return parse_color(value),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn blocks(css: &str) -> Vec<Block> {
|
||||
let mut text = String::with_capacity(css.len());
|
||||
let mut rest = css;
|
||||
while let Some(start) = rest.find("/*") {
|
||||
text.push_str(&rest[..start]);
|
||||
rest = rest[start + 2..].find("*/").map_or("", |end| &rest[start + 2 + end + 2..]);
|
||||
}
|
||||
text.push_str(rest);
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut rest = text.as_str();
|
||||
while let Some(open) = rest.find('{') {
|
||||
let selector = rest[..open].trim();
|
||||
// Matching close brace (nested at-rule blocks are skipped whole).
|
||||
let mut depth = 0;
|
||||
let Some(close) = rest[open..].char_indices().find_map(|(i, c)| {
|
||||
match c {
|
||||
'{' => depth += 1,
|
||||
'}' => depth -= 1,
|
||||
_ => {}
|
||||
}
|
||||
(depth == 0).then_some(open + i)
|
||||
}) else {
|
||||
break;
|
||||
};
|
||||
if !selector.starts_with('@') {
|
||||
let decls = rest[open + 1..close]
|
||||
.split(';')
|
||||
.filter_map(|d| d.split_once(':'))
|
||||
.filter(|(k, _)| k.trim().starts_with("--"))
|
||||
.map(|(k, v)| (k.trim().to_owned(), v.trim().to_owned()))
|
||||
.collect();
|
||||
out.push((selector.split(',').map(|s| s.trim().to_owned()).collect(), decls));
|
||||
}
|
||||
rest = &rest[close + 1..];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `#hex` or `hsl(h, s%, l%)`, rounded to 8-bit channels as browsers do.
|
||||
fn parse_color(value: &str) -> Option<Color> {
|
||||
if value.starts_with('#') {
|
||||
return serde_json::from_value(serde_json::Value::String(value.to_owned())).ok();
|
||||
}
|
||||
let args = value.strip_prefix("hsl(")?.strip_suffix(')')?;
|
||||
let nums: Vec<f64> = args
|
||||
.split([',', ' '])
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.trim_end_matches("deg").trim_end_matches('%').parse())
|
||||
.collect::<Result<_, _>>()
|
||||
.ok()?;
|
||||
let [h, s, l] = nums[..] else { return None };
|
||||
let (s, l) = (s / 100.0, l / 100.0);
|
||||
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
||||
let hp = (h.rem_euclid(360.0)) / 60.0;
|
||||
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
|
||||
let (r, g, b) = match hp as u8 {
|
||||
0 => (c, x, 0.0),
|
||||
1 => (x, c, 0.0),
|
||||
2 => (0.0, c, x),
|
||||
3 => (0.0, x, c),
|
||||
4 => (x, 0.0, c),
|
||||
_ => (c, 0.0, x),
|
||||
};
|
||||
let m = l - c / 2.0;
|
||||
let byte = |v: f64| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
Some(Color(byte(r), byte(g), byte(b), 0xff))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_every_builtin_frame_from_tokens_css() {
|
||||
for (id, color) in [
|
||||
("light", Color(0xe9, 0xea, 0xee, 0xff)),
|
||||
("dark", Color(0x26, 0x28, 0x2c, 0xff)),
|
||||
("dark-hc", Color(0x1a, 0x1b, 0x1e, 0xff)),
|
||||
("solarized-light", Color(0xee, 0xe8, 0xd5, 0xff)),
|
||||
("solarized-dark", Color(0x07, 0x36, 0x42, 0xff)),
|
||||
("one-light", Color(0xea, 0xea, 0xeb, 0xff)),
|
||||
("one-dark", Color(0x21, 0x25, 0x2b, 0xff)),
|
||||
] {
|
||||
assert_eq!(builtin_frame(id), Some(color), "{id}");
|
||||
}
|
||||
assert_eq!(builtin_frame("system"), None);
|
||||
assert_eq!(builtin_frame("nope"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_var_chains_and_hsl() {
|
||||
let css = r#"
|
||||
/* a { comment } */
|
||||
:root { --bg-frame: #000; --p: hsl(0, 100%, 50%); }
|
||||
@media (x) { :root { --bg-frame: #fff; } }
|
||||
:root[data-color-theme] { --bg-frame: var(--q); }
|
||||
:root[data-color-theme="t-dark"] { --q: var(--p, #123); }
|
||||
"#;
|
||||
let b = blocks(css);
|
||||
assert_eq!(builtin_frame_in(&b, "dark"), Some(Color(0, 0, 0, 0xff)));
|
||||
assert_eq!(builtin_frame_in(&b, "t-dark"), Some(Color(0xff, 0, 0, 0xff)));
|
||||
assert_eq!(builtin_frame_in(&b, "u-dark"), None);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
//! [`Launch::settings_page`] tells it which page to show.
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::theme_frame::builtin_frame;
|
||||
use ide_core::settings::AppearanceSettings;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
@@ -212,22 +213,15 @@ fn window_config(app: &AppHandle) -> IdeResult<WindowConfig> {
|
||||
|
||||
/// The theme's `--bg-frame` (tokens.css), mirroring `resolveThemeId` in theme.ts.
|
||||
pub fn frame_color(appearance: &AppearanceSettings, os_dark: bool) -> Color {
|
||||
const LIGHT: Color = Color(0xe9, 0xea, 0xee, 0xff);
|
||||
const DARK: Color = Color(0x26, 0x28, 0x2c, 0xff);
|
||||
const DARK_HC: Color = Color(0x1a, 0x1b, 0x1e, 0xff);
|
||||
let sync = appearance.sync_with_os || appearance.theme == "system";
|
||||
let theme = match (sync, os_dark) {
|
||||
(false, _) => &appearance.theme,
|
||||
(true, true) => &appearance.preferred_dark_theme,
|
||||
(true, false) => &appearance.preferred_light_theme,
|
||||
};
|
||||
match theme.as_str() {
|
||||
"dark-hc" => DARK_HC,
|
||||
"dark" => DARK,
|
||||
"light" => LIGHT,
|
||||
_ if sync && os_dark => DARK,
|
||||
_ => LIGHT,
|
||||
}
|
||||
builtin_frame(theme)
|
||||
.or_else(|| builtin_frame(if sync && os_dark { "dark" } else { "light" }))
|
||||
.unwrap_or(Color(0xe9, 0xea, 0xee, 0xff))
|
||||
}
|
||||
|
||||
/// A `#rgb` / `#rrggbb` / `#rrggbbaa` colour sent by the UI (Tauri's own parser).
|
||||
@@ -475,6 +469,25 @@ mod tests {
|
||||
assert_eq!(frame_color(&a, true), Color(0x26, 0x28, 0x2c, 0xff));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn additional_palettes_keep_native_startup_and_os_preferences_in_sync() {
|
||||
for (id, color) in [
|
||||
("solarized-light", Color(0xee, 0xe8, 0xd5, 0xff)),
|
||||
("solarized-dark", Color(0x07, 0x36, 0x42, 0xff)),
|
||||
("one-light", Color(0xea, 0xea, 0xeb, 0xff)),
|
||||
("one-dark", Color(0x21, 0x25, 0x2b, 0xff)),
|
||||
] {
|
||||
let mut a = AppearanceSettings { theme: id.into(), ..Default::default() };
|
||||
assert_eq!(frame_color(&a, false), color);
|
||||
assert_eq!(frame_color(&a, true), color);
|
||||
a.sync_with_os = true;
|
||||
a.preferred_light_theme = id.into();
|
||||
a.preferred_dark_theme = id.into();
|
||||
assert_eq!(frame_color(&a, false), color);
|
||||
assert_eq!(frame_color(&a, true), color);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_colours_from_the_ui_parse_as_hex() {
|
||||
for (hex, expected) in [
|
||||
|
||||
Reference in new issue
Заблокувати користувача