Add resolved appearance engine and paired theme settings
Resolve light, dark and automatic appearance centrally and publish live theme snapshots to native surfaces and the appearance portal. Add paired preset families, custom theme imports, accessibility transforms and contrast-safe transitions. Refine the Settings gallery and Auto controls, move overrides under advanced customization, and share palette-aware switches and circular slider thumbs. Document configuration and include theme licenses and integration coverage.
This commit is contained in:
1 parent
2bb4c086f5
commit
3d950a0411
58 files changed
+5556
-644
No files matched your search
@@ -6,6 +6,10 @@ license.workspace = true
|
||||
rust-version = "1.95"
|
||||
|
||||
[dependencies]
|
||||
jiff.workspace = true
|
||||
kdl = "6.7.1"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,92 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::theme::{Appearance, Tokens};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Palette {
|
||||
pub background: String,
|
||||
pub bar: String,
|
||||
pub surface: String,
|
||||
pub text: String,
|
||||
pub muted: String,
|
||||
pub accent: String,
|
||||
}
|
||||
|
||||
impl From<&Tokens> for Palette {
|
||||
fn from(tokens: &Tokens) -> Self {
|
||||
Self {
|
||||
background: tokens.colors.surface_base.clone(),
|
||||
bar: tokens.surface.bar.background.clone(),
|
||||
surface: tokens.colors.surface_raised.clone(),
|
||||
text: tokens.colors.text_primary.clone(),
|
||||
muted: tokens.colors.text_muted.clone(),
|
||||
accent: tokens.colors.accent.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Family {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub light: Option<Palette>,
|
||||
pub dark: Option<Palette>,
|
||||
}
|
||||
|
||||
impl Family {
|
||||
pub fn palette(&self, appearance: Appearance) -> Option<&Palette> {
|
||||
match appearance {
|
||||
Appearance::Light => self.light.as_ref(),
|
||||
Appearance::Dark => self.dark.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn availability(&self) -> &'static str {
|
||||
match (self.light.is_some(), self.dark.is_some()) {
|
||||
(true, true) => "light and dark",
|
||||
(true, false) => "light only",
|
||||
_ => "dark only",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const BUILTINS: [(&str, &str, &str, &str); 6] = [
|
||||
("ferese-blue", "Ferese Blue", "ferese-blue-light", "ferese-blue"),
|
||||
("catppuccin", "Catppuccin", "catppuccin-latte", "catppuccin-mocha"),
|
||||
("gruvbox", "Gruvbox", "gruvbox-light", "gruvbox"),
|
||||
("rose-pine", "Rosé Pine", "rose-pine-dawn", "rose-pine-moon"),
|
||||
("tokyo-night", "Tokyo Night", "tokyo-night-day", "tokyo-night"),
|
||||
("everforest", "Everforest", "everforest-light", "everforest-dark"),
|
||||
];
|
||||
|
||||
pub fn family_id(preset: &str) -> &str {
|
||||
BUILTINS
|
||||
.iter()
|
||||
.find(|row| row.2 == preset || row.3 == preset)
|
||||
.map_or(preset, |row| row.0)
|
||||
}
|
||||
|
||||
pub fn variant(id: &str, appearance: Appearance) -> Option<&'static str> {
|
||||
if let Some(row) = BUILTINS.iter().find(|row| row.0 == id) {
|
||||
return Some(if appearance == Appearance::Light { row.2 } else { row.3 });
|
||||
}
|
||||
match (id, appearance) {
|
||||
("dracula", Appearance::Dark) => Some("dracula"),
|
||||
("monochrome", Appearance::Dark) => Some("monochrome"),
|
||||
("monokai", Appearance::Dark) => Some("monokai"),
|
||||
("ayu-light", Appearance::Light) => Some("ayu-light"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn builtins() -> Vec<Family> {
|
||||
BUILTINS
|
||||
.iter()
|
||||
.map(|(id, name, light, dark)| Family {
|
||||
id: (*id).into(),
|
||||
name: (*name).into(),
|
||||
light: Some(Palette::from(&crate::theme::preset(light, Appearance::Light).unwrap())),
|
||||
dark: Some(Palette::from(&crate::theme::preset(dark, Appearance::Dark).unwrap())),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
pub mod families;
|
||||
pub mod presets;
|
||||
pub mod theme;
|
||||
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -45,7 +49,7 @@ pub fn default_wallpaper() -> &'static str {
|
||||
}
|
||||
|
||||
fn field(name: &str, parent: &str) -> String {
|
||||
if parent == "commands" {
|
||||
if matches!(parent, "commands" | "custom_themes") {
|
||||
return name.to_owned();
|
||||
}
|
||||
|
||||
@@ -210,6 +214,14 @@ impl Document {
|
||||
&self.value
|
||||
}
|
||||
|
||||
/// A runtime view; the original KDL remains available for comment-preserving edits.
|
||||
pub fn with_theme(&self, theme: &theme::ResolvedTheme) -> Self {
|
||||
let mut document = self.clone();
|
||||
document.value["theme"] = serde_json::to_value(&theme.tokens).expect("serializable theme tokens");
|
||||
document.value["theme"]["appearance"] = serde_json::to_value(theme.appearance).unwrap();
|
||||
document
|
||||
}
|
||||
|
||||
pub fn get(&self, path: &str) -> Option<&Value> {
|
||||
let mut value = &self.value;
|
||||
|
||||
@@ -229,7 +241,26 @@ impl Document {
|
||||
}
|
||||
|
||||
pub fn set(&mut self, path: &str, value: Value) -> Result<(), Error> {
|
||||
if value.as_str() == Some("")
|
||||
&& matches!(
|
||||
path,
|
||||
"theme.file" | "theme.light.file" | "theme.dark.file" | "theme.accent"
|
||||
)
|
||||
{
|
||||
return self.unset(path);
|
||||
}
|
||||
let mut candidate = self.clone();
|
||||
if matches!(
|
||||
path,
|
||||
"theme.mode"
|
||||
| "theme.family"
|
||||
| "theme.light.family"
|
||||
| "theme.dark.family"
|
||||
| "theme.light.preset"
|
||||
| "theme.dark.preset"
|
||||
) {
|
||||
candidate.migrate_theme_selection()?;
|
||||
}
|
||||
let parts = path.split('.').collect::<Vec<_>>();
|
||||
set_in(&mut candidate.doc, &parts, "", value)?;
|
||||
format_document(&mut candidate.doc);
|
||||
@@ -262,6 +293,67 @@ impl Document {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_theme_selection(&mut self) -> Result<(), Error> {
|
||||
if self.get("theme.mode").is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.get("theme.light").is_some() || self.get("theme.dark").is_some() {
|
||||
set_in(&mut self.doc, &["theme", "mode"], "", Value::String("dark".into()))?;
|
||||
return self.refresh();
|
||||
}
|
||||
let Some(index) = self.doc.nodes().iter().position(|node| node.name().value() == "theme") else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(children) = self.doc.nodes_mut()[index].children_mut().as_mut() {
|
||||
let mut moved = Vec::new();
|
||||
for name in ["colors", "surface", "border", "focus-ring"] {
|
||||
if let Some(index) = children.nodes().iter().position(|node| node.name().value() == name) {
|
||||
moved.push(children.nodes_mut().remove(index));
|
||||
}
|
||||
}
|
||||
if !moved.is_empty() {
|
||||
let dark = section_mut(children, &["dark"], "theme")?;
|
||||
dark.nodes_mut().extend(moved);
|
||||
}
|
||||
}
|
||||
set_in(&mut self.doc, &["theme", "mode"], "", Value::String("dark".into()))?;
|
||||
self.refresh()
|
||||
}
|
||||
|
||||
pub fn unset(&mut self, path: &str) -> Result<(), Error> {
|
||||
if self.get(path).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut candidate = self.clone();
|
||||
let (name, parents) = path.rsplit_once('.').map_or((path, ""), |(p, n)| (n, p));
|
||||
let doc = section_mut(
|
||||
&mut candidate.doc,
|
||||
&parents.split('.').filter(|s| !s.is_empty()).collect::<Vec<_>>(),
|
||||
"",
|
||||
)?;
|
||||
if let Some(index) = doc
|
||||
.nodes()
|
||||
.iter()
|
||||
.position(|node| field(node.name().value(), parents.rsplit('.').next().unwrap_or("")) == name)
|
||||
{
|
||||
let node = doc.nodes_mut().remove(index);
|
||||
let mut comments = String::new();
|
||||
collect_comments(&node, &mut comments);
|
||||
if let Some(format) = doc.format_mut() {
|
||||
format.trailing.push_str(&comments);
|
||||
} else {
|
||||
doc.set_format(kdl::KdlDocumentFormat {
|
||||
leading: String::new(),
|
||||
trailing: comments,
|
||||
});
|
||||
}
|
||||
}
|
||||
format_document(&mut candidate.doc);
|
||||
candidate.refresh()?;
|
||||
*self = candidate;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, path: &str, index: usize) -> Result<(), Error> {
|
||||
let mut candidate = self.clone();
|
||||
let (last, parents) = path.rsplit_once('.').map_or((path, ""), |(p, l)| (l, p));
|
||||
@@ -288,6 +380,37 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_comments(node: &KdlNode, output: &mut String) {
|
||||
let mut keep = |text: &str| {
|
||||
if text.contains("//") || text.contains("/*") {
|
||||
output.push_str(text);
|
||||
output.push('\n');
|
||||
}
|
||||
};
|
||||
if let Some(format) = node.format() {
|
||||
keep(&format.leading);
|
||||
keep(&format.before_children);
|
||||
keep(&format.before_terminator);
|
||||
keep(&format.terminator);
|
||||
keep(&format.trailing);
|
||||
}
|
||||
for entry in node.entries() {
|
||||
if let Some(format) = entry.format() {
|
||||
keep(&format.leading);
|
||||
keep(&format.trailing);
|
||||
}
|
||||
}
|
||||
if let Some(children) = node.children() {
|
||||
if let Some(format) = children.format() {
|
||||
keep(&format.leading);
|
||||
keep(&format.trailing);
|
||||
}
|
||||
for child in children.nodes() {
|
||||
collect_comments(child, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Document {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.doc.fmt(f)
|
||||
@@ -506,7 +629,7 @@ fn kdl_value(value: &Value) -> Result<KdlValue, Error> {
|
||||
}
|
||||
|
||||
fn node_name(key: &str, parent: &str) -> String {
|
||||
if parent == "commands" {
|
||||
if matches!(parent, "commands" | "custom_themes") {
|
||||
return key.into();
|
||||
}
|
||||
|
||||
@@ -599,6 +722,33 @@ pub fn from_str<T: DeserializeOwned>(source: &str) -> Result<T, Error> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn adopting_appearance_modes_preserves_legacy_colors_and_comments() {
|
||||
let mut document = Document::parse(
|
||||
"// my desktop\ntheme {\n colors {\n accent \"#123456\" // my accent\n }\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
document.set("theme.mode", serde_json::json!("light")).unwrap();
|
||||
assert_eq!(
|
||||
document.get("theme.dark.colors.accent"),
|
||||
Some(&serde_json::json!("#123456"))
|
||||
);
|
||||
assert!(document.get("theme.colors").is_none());
|
||||
assert!(document.to_string().contains("// my accent"));
|
||||
document.unset("theme.dark.colors").unwrap();
|
||||
assert!(document.to_string().contains("// my accent"));
|
||||
assert!(document.to_string().contains("// my desktop"));
|
||||
Document::parse(&document.to_string()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_optional_theme_references_removes_them_without_empty_paths() {
|
||||
let mut document = Document::parse("theme { file \"custom_theme.kdl\"; } ").unwrap();
|
||||
document.set("theme.file", serde_json::json!("")).unwrap();
|
||||
assert!(document.get("theme.file").is_none());
|
||||
document.set("theme.file", serde_json::json!("")).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widget_output_arrays_and_command_names_survive_edits() {
|
||||
let source = r#"commands {
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
use crate::theme::Appearance;
|
||||
|
||||
pub struct Preset {
|
||||
pub id: &'static str,
|
||||
pub appearance: Appearance,
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
pub base: &'static str,
|
||||
pub raised: &'static str,
|
||||
pub application_background: &'static str,
|
||||
pub text: &'static str,
|
||||
pub muted: &'static str,
|
||||
pub accent: &'static str,
|
||||
pub border: &'static str,
|
||||
pub gradient_end: &'static str,
|
||||
pub opacity: f64,
|
||||
pub blur_radius: f64,
|
||||
pub tint_strength: f64,
|
||||
pub shadow_opacity: f64,
|
||||
}
|
||||
|
||||
pub const PRESETS: [Preset; 12] = [
|
||||
Preset {
|
||||
appearance: Appearance::Dark,
|
||||
id: "ferese-blue",
|
||||
name: "Ferese Blue",
|
||||
description: "Default",
|
||||
base: "#111821",
|
||||
raised: "#1E2530",
|
||||
application_background: "#171E27",
|
||||
text: "#F4F7FB",
|
||||
muted: "#8793A2",
|
||||
accent: "#3D7BE6",
|
||||
border: "#FFFFFF18",
|
||||
gradient_end: "#3D7BE6",
|
||||
opacity: 0.78,
|
||||
blur_radius: 12.0,
|
||||
tint_strength: 0.5,
|
||||
shadow_opacity: 0.2,
|
||||
},
|
||||
Preset {
|
||||
appearance: Appearance::Dark,
|
||||
id: "catppuccin-mocha",
|
||||
name: "Catppuccin",
|
||||
description: "Mocha",
|
||||
base: "#1E1E2E",
|
||||
raised: "#313244",
|
||||
application_background: "#1E1E2E",
|
||||
text: "#CDD6F4",
|
||||
muted: "#A6ADC8",
|
||||
accent: "#CBA6F7",
|
||||
border: "#45475A88",
|
||||
gradient_end: "#B4BEFE",
|
||||
opacity: 0.84,
|
||||
blur_radius: 18.0,
|
||||
tint_strength: 0.62,
|
||||
shadow_opacity: 0.2,
|
||||
},
|
||||
Preset {
|
||||
appearance: Appearance::Dark,
|
||||
id: "gruvbox",
|
||||
name: "Gruvbox",
|
||||
description: "Dark",
|
||||
base: "#282828",
|
||||
raised: "#3C3836",
|
||||
application_background: "#282828",
|
||||
text: "#EBDBB2",
|
||||
muted: "#BDAE93",
|
||||
accent: "#FE8019",
|
||||
border: "#50494588",
|
||||
gradient_end: "#FABD2F",
|
||||
opacity: 0.86,
|
||||
blur_radius: 14.0,
|
||||
tint_strength: 0.66,
|
||||
shadow_opacity: 0.22,
|
||||
},
|
||||
Preset {
|
||||
appearance: Appearance::Dark,
|
||||
id: "rose-pine-moon",
|
||||
name: "Rosé Pine",
|
||||
description: "Moon",
|
||||
base: "#232136",
|
||||
raised: "#2A273F",
|
||||
application_background: "#232136",
|
||||
text: "#E0DEF4",
|
||||
muted: "#918DAA",
|
||||
accent: "#EA9A97",
|
||||
border: "#56526E88",
|
||||
gradient_end: "#C4A7E7",
|
||||
opacity: 0.88,
|
||||
blur_radius: 18.0,
|
||||
tint_strength: 0.7,
|
||||
shadow_opacity: 0.18,
|
||||
},
|
||||
Preset {
|
||||
appearance: Appearance::Dark,
|
||||
id: "tokyo-night",
|
||||
name: "Tokyo Night",
|
||||
description: "Night",
|
||||
base: "#1A1B26",
|
||||
raised: "#292E42",
|
||||
application_background: "#1A1B26",
|
||||
text: "#C0CAF5",
|
||||
muted: "#A9B1D6",
|
||||
accent: "#7AA2F7",
|
||||
border: "#41486888",
|
||||
gradient_end: "#BB9AF7",
|
||||
opacity: 0.85,
|
||||
blur_radius: 20.0,
|
||||
tint_strength: 0.66,
|
||||
shadow_opacity: 0.22,
|
||||
},
|
||||
Preset {
|
||||
appearance: Appearance::Dark,
|
||||
id: "everforest-dark",
|
||||
name: "Everforest",
|
||||
description: "Dark · hard",
|
||||
base: "#272E33",
|
||||
raised: "#2E383C",
|
||||
application_background: "#272E33",
|
||||
text: "#D3C6AA",
|
||||
muted: "#9DA9A0",
|
||||
accent: "#A7C080",
|
||||
border: "#49535B88",
|
||||
gradient_end: "#83C092",
|
||||
opacity: 0.9,
|
||||
blur_radius: 14.0,
|
||||
tint_strength: 0.7,
|
||||
shadow_opacity: 0.18,
|
||||
},
|
||||
Preset {
|
||||
appearance: Appearance::Light,
|
||||
id: "ferese-blue-light",
|
||||
name: "Ferese Blue",
|
||||
description: "Default",
|
||||
base: "#F5F7FB",
|
||||
raised: "#E7E9ED",
|
||||
application_background: "#EFF1F5",
|
||||
text: "#182234",
|
||||
muted: "#586579",
|
||||
accent: "#3D7BE6",
|
||||
border: "#18223430",
|
||||
gradient_end: "#3D7BE6",
|
||||
opacity: 0.94,
|
||||
blur_radius: 10.0,
|
||||
tint_strength: 1.0,
|
||||
shadow_opacity: 0.1,
|
||||
},
|
||||
Preset {
|
||||
appearance: Appearance::Light,
|
||||
id: "catppuccin-latte",
|
||||
name: "Catppuccin",
|
||||
description: "Latte",
|
||||
base: "#EFF1F5",
|
||||
raised: "#E6E9EF",
|
||||
application_background: "#EFF1F5",
|
||||
text: "#4C4F69",
|
||||
muted: "#5C5F77",
|
||||
accent: "#8839EF",
|
||||
border: "#9CA0B066",
|
||||
gradient_end: "#7287FD",
|
||||
opacity: 0.95,
|
||||
blur_radius: 12.0,
|
||||
tint_strength: 1.0,
|
||||
shadow_opacity: 0.08,
|
||||
},
|
||||
Preset {
|
||||
appearance: Appearance::Light,
|
||||
id: "gruvbox-light",
|
||||
name: "Gruvbox",
|
||||
description: "Light",
|
||||
base: "#FBF1C7",
|
||||
raised: "#F2E5BC",
|
||||
application_background: "#FBF1C7",
|
||||
text: "#3C3836",
|
||||
muted: "#665C54",
|
||||
accent: "#AF3A03",
|
||||
border: "#92837466",
|
||||
gradient_end: "#B57614",
|
||||
opacity: 0.96,
|
||||
blur_radius: 9.0,
|
||||
tint_strength: 1.0,
|
||||
shadow_opacity: 0.11,
|
||||
},
|
||||
Preset {
|
||||
appearance: Appearance::Light,
|
||||
id: "rose-pine-dawn",
|
||||
name: "Rosé Pine",
|
||||
description: "Dawn",
|
||||
base: "#FAF4ED",
|
||||
raised: "#FFFAF3",
|
||||
application_background: "#FAF4ED",
|
||||
text: "#464261",
|
||||
muted: "#716D89",
|
||||
accent: "#B4637A",
|
||||
border: "#CECACD66",
|
||||
gradient_end: "#907AA9",
|
||||
opacity: 0.96,
|
||||
blur_radius: 12.0,
|
||||
tint_strength: 1.0,
|
||||
shadow_opacity: 0.07,
|
||||
},
|
||||
Preset {
|
||||
appearance: Appearance::Light,
|
||||
id: "tokyo-night-day",
|
||||
name: "Tokyo Night",
|
||||
description: "Day",
|
||||
base: "#E1E2E7",
|
||||
raised: "#D0D5E3",
|
||||
application_background: "#E1E2E7",
|
||||
text: "#3358B0",
|
||||
muted: "#4D5A8C",
|
||||
accent: "#2C76DD",
|
||||
border: "#8990B366",
|
||||
gradient_end: "#9854F1",
|
||||
opacity: 0.96,
|
||||
blur_radius: 10.0,
|
||||
tint_strength: 1.0,
|
||||
shadow_opacity: 0.1,
|
||||
},
|
||||
Preset {
|
||||
appearance: Appearance::Light,
|
||||
id: "everforest-light",
|
||||
name: "Everforest",
|
||||
description: "Light · hard",
|
||||
base: "#FFFBEF",
|
||||
raised: "#F8F5E4",
|
||||
application_background: "#FFFBEF",
|
||||
text: "#5C6A72",
|
||||
muted: "#677367",
|
||||
accent: "#849601",
|
||||
border: "#939F9166",
|
||||
gradient_end: "#35A77C",
|
||||
opacity: 0.96,
|
||||
blur_radius: 8.0,
|
||||
tint_strength: 1.0,
|
||||
shadow_opacity: 0.08,
|
||||
},
|
||||
];
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user