| 1 |
|
- |
//! Shared theme loading logic for TOML-based theme files.
|
|
1 |
+ |
//! Shared theme loading + intent resolution for TOML-based theme files.
|
| 2 |
2 |
|
//!
|
| 3 |
|
- |
//! Used by GoingsOn, Balanced Breakfast (Tauri apps), and audiofiles (egui).
|
| 4 |
|
- |
//! audiofiles embeds themes at compile time but uses `ThemeMeta`, `parse_meta`,
|
| 5 |
|
- |
//! and `extract_colors` from this crate.
|
|
3 |
+ |
//! Used by GoingsOn, Balanced Breakfast (Tauri apps), audiofiles (egui), and the
|
|
4 |
+ |
//! MNW web server. Themes are authored by **intent** ("human design"): colors are
|
|
5 |
+ |
//! declared by role (surface / content / action / status / line / category), not
|
|
6 |
+ |
//! by hue. This crate is the single place that resolves an authored theme into a
|
|
7 |
+ |
//! full set of intent tokens — including the derived interactive states
|
|
8 |
+ |
//! (hover/active/selection/row-stripe/contrast) that each app used to recompute
|
|
9 |
+ |
//! itself — and emits them as CSS variables or RGB tuples.
|
| 6 |
10 |
|
//!
|
| 7 |
|
- |
//! Theme files are TOML with this structure:
|
|
11 |
+ |
//! Theme file shape:
|
| 8 |
12 |
|
//! ```text
|
| 9 |
13 |
|
//! [meta]
|
| 10 |
|
- |
//! name = "Theme Name"
|
| 11 |
|
- |
//! variant = "dark" # or "light"
|
|
14 |
+ |
//! name = "Nord"
|
|
15 |
+ |
//! variant = "dark" # or "light"
|
| 12 |
16 |
|
//!
|
| 13 |
|
- |
//! [background]
|
| 14 |
|
- |
//! primary = "#1e1e2e"
|
|
17 |
+ |
//! [surface] # container backgrounds by role/elevation
|
|
18 |
+ |
//! page = "#2e3440"; raised = "#3b4252"; sunken = "#434c5e"; overlay = "#3b4252"
|
| 15 |
19 |
|
//!
|
| 16 |
|
- |
//! [foreground]
|
| 17 |
|
- |
//! primary = "#cdd6f4"
|
|
20 |
+ |
//! [content] # text/ink by emphasis
|
|
21 |
+ |
//! primary = "#d8dee9"; secondary = "#e5e9f0"; muted = "#616e88"
|
| 18 |
22 |
|
//!
|
| 19 |
|
- |
//! [accent]
|
| 20 |
|
- |
//! primary = "#89b4fa"
|
|
23 |
+ |
//! [action] # interactive / brand color
|
|
24 |
+ |
//! primary = "#81a1c1"
|
| 21 |
25 |
|
//!
|
| 22 |
|
- |
//! [border]
|
| 23 |
|
- |
//! primary = "#45475a"
|
|
26 |
+ |
//! [status] # state semantics
|
|
27 |
+ |
//! danger = "#bf616a"; success = "#a3be8c"; warning = "#ebcb8b"; info = "#88c0d0"
|
|
28 |
+ |
//!
|
|
29 |
+ |
//! [line]
|
|
30 |
+ |
//! border = "#4c566a"
|
|
31 |
+ |
//!
|
|
32 |
+ |
//! [category] # distinct decorative colors for tags/badges/charts
|
|
33 |
+ |
//! one = "#bf616a"; two = "#a3be8c"; three = "#81a1c1"
|
|
34 |
+ |
//! four = "#ebcb8b"; five = "#b48ead"; six = "#88c0d0"
|
| 24 |
35 |
|
//! ```
|
| 25 |
36 |
|
|
| 26 |
37 |
|
use serde::Serialize;
|
| 27 |
|
- |
use std::collections::HashMap;
|
|
38 |
+ |
use std::collections::{BTreeMap, HashMap};
|
| 28 |
39 |
|
use std::path::{Path, PathBuf};
|
| 29 |
40 |
|
|
|
41 |
+ |
/// The color sections an authored theme may declare.
|
|
42 |
+ |
pub const COLOR_SECTIONS: &[&str] = &["surface", "content", "action", "status", "line", "category"];
|
|
43 |
+ |
|
| 30 |
44 |
|
/// Theme metadata parsed from the `[meta]` section.
|
| 31 |
45 |
|
#[derive(Debug, Clone, Serialize)]
|
| 32 |
46 |
|
#[serde(rename_all = "camelCase")]
|
| 37 |
51 |
|
pub is_custom: bool,
|
| 38 |
52 |
|
}
|
| 39 |
53 |
|
|
| 40 |
|
- |
/// A fully loaded theme: metadata plus flattened color map.
|
|
54 |
+ |
/// A loaded theme: metadata plus the authored colors, flattened to dotted keys
|
|
55 |
+ |
/// (e.g. `"surface.page"`, `"status.danger"`, `"category.one"`).
|
| 41 |
56 |
|
#[derive(Debug, Serialize)]
|
| 42 |
57 |
|
#[serde(rename_all = "camelCase")]
|
| 43 |
58 |
|
pub struct ThemeColors {
|
| 45 |
60 |
|
pub colors: HashMap<String, String>,
|
| 46 |
61 |
|
}
|
| 47 |
62 |
|
|
|
63 |
+ |
// ============================================================================
|
|
64 |
+ |
// Color math (single source of truth for the four derivation ops)
|
|
65 |
+ |
// ============================================================================
|
|
66 |
+ |
|
|
67 |
+ |
/// An sRGB color. Hex round-trips losslessly.
|
|
68 |
+ |
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
69 |
+ |
pub struct Rgb {
|
|
70 |
+ |
pub r: u8,
|
|
71 |
+ |
pub g: u8,
|
|
72 |
+ |
pub b: u8,
|
|
73 |
+ |
}
|
|
74 |
+ |
|
|
75 |
+ |
impl Rgb {
|
|
76 |
+ |
/// Parse `#rgb` or `#rrggbb` (case-insensitive). Returns `None` otherwise.
|
|
77 |
+ |
pub fn from_hex(s: &str) -> Option<Rgb> {
|
|
78 |
+ |
let h = s.strip_prefix('#')?;
|
|
79 |
+ |
let (r, g, b) = match h.len() {
|
|
80 |
+ |
6 => (
|
|
81 |
+ |
u8::from_str_radix(&h[0..2], 16).ok()?,
|
|
82 |
+ |
u8::from_str_radix(&h[2..4], 16).ok()?,
|
|
83 |
+ |
u8::from_str_radix(&h[4..6], 16).ok()?,
|
|
84 |
+ |
),
|
|
85 |
+ |
3 => {
|
|
86 |
+ |
let d = |c: &str| u8::from_str_radix(c, 16).ok().map(|v| v * 17);
|
|
87 |
+ |
(d(&h[0..1])?, d(&h[1..2])?, d(&h[2..3])?)
|
|
88 |
+ |
}
|
|
89 |
+ |
_ => return None,
|
|
90 |
+ |
};
|
|
91 |
+ |
Some(Rgb { r, g, b })
|
|
92 |
+ |
}
|
|
93 |
+ |
|
|
94 |
+ |
/// Lowercase `#rrggbb`.
|
|
95 |
+ |
pub fn to_hex(self) -> String {
|
|
96 |
+ |
format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
|
|
97 |
+ |
}
|
|
98 |
+ |
|
|
99 |
+ |
pub fn tuple(self) -> (u8, u8, u8) {
|
|
100 |
+ |
(self.r, self.g, self.b)
|
|
101 |
+ |
}
|
|
102 |
+ |
}
|
|
103 |
+ |
|
|
104 |
+ |
/// ITU-R BT.601 relative luminance in [0,1].
|
|
105 |
+ |
fn luminance(c: Rgb) -> f32 {
|
|
106 |
+ |
(0.299 * c.r as f32 + 0.587 * c.g as f32 + 0.114 * c.b as f32) / 255.0
|
|
107 |
+ |
}
|
|
108 |
+ |
|
|
109 |
+ |
/// Black or white, whichever contrasts with `bg`. Matches BB `contrastColor`
|
|
110 |
+ |
/// and AF `contrast_color` (luminance > 0.5 → black).
|
|
111 |
+ |
pub fn contrast_color(bg: Rgb) -> Rgb {
|
|
112 |
+ |
if luminance(bg) > 0.5 {
|
|
113 |
+ |
Rgb { r: 0, g: 0, b: 0 }
|
|
114 |
+ |
} else {
|
|
115 |
+ |
Rgb { r: 255, g: 255, b: 255 }
|
|
116 |
+ |
}
|
|
117 |
+ |
}
|
|
118 |
+ |
|
|
119 |
+ |
/// Lighten each channel toward white by `pct` percent. Matches BB `lighten`.
|
|
120 |
+ |
pub fn lighten(c: Rgb, pct: f32) -> Rgb {
|
|
121 |
+ |
let f = |ch: u8| (ch as f32 + (255.0 - ch as f32) * (pct / 100.0)).round().clamp(0.0, 255.0) as u8;
|
|
122 |
+ |
Rgb { r: f(c.r), g: f(c.g), b: f(c.b) }
|
|
123 |
+ |
}
|
|
124 |
+ |
|
|
125 |
+ |
/// Darken each channel toward black by `pct` percent. Matches BB `darken`.
|
|
126 |
+ |
pub fn darken(c: Rgb, pct: f32) -> Rgb {
|
|
127 |
+ |
let f = |ch: u8| (ch as f32 * (1.0 - pct / 100.0)).round().clamp(0.0, 255.0) as u8;
|
|
128 |
+ |
Rgb { r: f(c.r), g: f(c.g), b: f(c.b) }
|
|
129 |
+ |
}
|
|
130 |
+ |
|
|
131 |
+ |
/// Linear blend from `a` to `b` by `t` in [0,1], per channel. Matches AF `lerp_color`.
|
|
132 |
+ |
pub fn lerp(a: Rgb, b: Rgb, t: f32) -> Rgb {
|
|
133 |
+ |
let f = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round().clamp(0.0, 255.0) as u8;
|
|
134 |
+ |
Rgb { r: f(a.r, b.r), g: f(a.g, b.g), b: f(a.b, b.b) }
|
|
135 |
+ |
}
|
|
136 |
+ |
|
|
137 |
+ |
// ============================================================================
|
|
138 |
+ |
// Intent resolution
|
|
139 |
+ |
// ============================================================================
|
|
140 |
+ |
|
|
141 |
+ |
/// Authored base intents: (TOML dotted source key, canonical token key).
|
|
142 |
+ |
/// These are read straight from the theme; the token key is the CSS-var stem
|
|
143 |
+ |
/// (`--{token}`) and the `rgb()` lookup key.
|
|
144 |
+ |
pub const BASE_INTENTS: &[(&str, &str)] = &[
|
|
145 |
+ |
("surface.page", "surface-page"),
|
|
146 |
+ |
("surface.raised", "surface-raised"),
|
|
147 |
+ |
("surface.sunken", "surface-sunken"),
|
|
148 |
+ |
("surface.overlay", "surface-overlay"),
|
|
149 |
+ |
("content.primary", "content"),
|
|
150 |
+ |
("content.secondary", "content-secondary"),
|
|
151 |
+ |
("content.muted", "content-muted"),
|
|
152 |
+ |
("action.primary", "action"),
|
|
153 |
+ |
("status.danger", "danger"),
|
|
154 |
+ |
("status.success", "success"),
|
|
155 |
+ |
("status.warning", "warning"),
|
|
156 |
+ |
("status.info", "info"),
|
|
157 |
+ |
("line.border", "border"),
|
|
158 |
+ |
("category.one", "category-one"),
|
|
159 |
+ |
("category.two", "category-two"),
|
|
160 |
+ |
("category.three", "category-three"),
|
|
161 |
+ |
("category.four", "category-four"),
|
|
162 |
+ |
("category.five", "category-five"),
|
|
163 |
+ |
("category.six", "category-six"),
|
|
164 |
+ |
];
|
|
165 |
+ |
|
|
166 |
+ |
/// A fully resolved intent layer: every token key → concrete `#rrggbb`.
|
|
167 |
+ |
/// Includes both authored base intents and the computed derived intents.
|
|
168 |
+ |
#[derive(Debug, Clone, Serialize)]
|
|
169 |
+ |
#[serde(rename_all = "camelCase")]
|
|
170 |
+ |
pub struct SemanticTokens {
|
|
171 |
+ |
pub meta: ThemeMeta,
|
|
172 |
+ |
/// token-key → resolved hex. Stable, deterministic ordering.
|
|
173 |
+ |
pub intents: BTreeMap<String, String>,
|
|
174 |
+ |
}
|
|
175 |
+ |
|
|
176 |
+ |
impl SemanticTokens {
|
|
177 |
+ |
/// Resolved hex for a token key, if present.
|
|
178 |
+ |
pub fn hex(&self, key: &str) -> Option<&str> {
|
|
179 |
+ |
self.intents.get(key).map(String::as_str)
|
|
180 |
+ |
}
|
|
181 |
+ |
|
|
182 |
+ |
/// Resolved RGB tuple for a token key (for egui / native consumers).
|
|
183 |
+ |
pub fn rgb(&self, key: &str) -> Option<(u8, u8, u8)> {
|
|
184 |
+ |
self.intents.get(key).and_then(|h| Rgb::from_hex(h)).map(Rgb::tuple)
|
|
185 |
+ |
}
|
|
186 |
+ |
}
|
|
187 |
+ |
|
|
188 |
+ |
/// Resolve an authored theme into the full intent token set.
|
|
189 |
+ |
///
|
|
190 |
+ |
/// 1. Copy each present base intent from the authored colors.
|
|
191 |
+ |
/// 2. Compute the derived interactive states from the base intents, using the
|
|
192 |
+ |
/// same math the apps used to apply individually (so output is identical).
|
|
193 |
+ |
/// Each derived token is emitted only when its source intents exist, mirroring
|
|
194 |
+ |
/// the skip-missing behavior of the rest of the crate.
|
|
195 |
+ |
pub fn resolve(theme: &ThemeColors) -> SemanticTokens {
|
|
196 |
+ |
let mut intents: BTreeMap<String, String> = BTreeMap::new();
|
|
197 |
+ |
|
|
198 |
+ |
// 1. Base intents (authored).
|
|
199 |
+ |
for (src, token) in BASE_INTENTS {
|
|
200 |
+ |
if let Some(v) = theme.colors.get(*src) {
|
|
201 |
+ |
intents.insert((*token).to_string(), v.clone());
|
|
202 |
+ |
}
|
|
203 |
+ |
}
|
|
204 |
+ |
|
|
205 |
+ |
// Helper: parse an already-resolved token to Rgb.
|
|
206 |
+ |
let get = |m: &BTreeMap<String, String>, k: &str| m.get(k).and_then(|h| Rgb::from_hex(h));
|
|
207 |
+ |
|
|
208 |
+ |
// 2. Derived intents.
|
|
209 |
+ |
let mut derived: Vec<(String, Rgb)> = Vec::new();
|
|
210 |
+ |
if let Some(action) = get(&intents, "action") {
|
|
211 |
+ |
derived.push(("action-hover".into(), lighten(action, 10.0)));
|
|
212 |
+ |
derived.push(("action-active".into(), darken(action, 10.0)));
|
|
213 |
+ |
derived.push(("content-on-action".into(), contrast_color(action)));
|
|
214 |
+ |
derived.push(("focus-ring".into(), action));
|
|
215 |
+ |
if let Some(page) = get(&intents, "surface-page") {
|
|
216 |
+ |
derived.push(("selection".into(), lerp(page, action, 0.3)));
|
|
217 |
+ |
}
|
|
218 |
+ |
}
|
|
219 |
+ |
if let Some(page) = get(&intents, "surface-page") {
|
|
220 |
+ |
if let Some(overlay) = get(&intents, "surface-overlay") {
|
|
221 |
+ |
derived.push(("row-stripe".into(), lerp(page, overlay, 0.3)));
|
|
222 |
+ |
}
|
|
223 |
+ |
for status in ["danger", "success", "warning", "info"] {
|
|
224 |
+ |
if let Some(c) = get(&intents, status) {
|
|
225 |
+ |
derived.push((format!("{status}-surface"), lerp(page, c, 0.12)));
|
|
226 |
+ |
}
|
|
227 |
+ |
}
|
|
228 |
+ |
}
|
|
229 |
+ |
if let Some(sunken) = get(&intents, "surface-sunken") {
|
|
230 |
+ |
derived.push(("hover-surface".into(), sunken));
|
|
231 |
+ |
}
|
|
232 |
+ |
if let Some(border) = get(&intents, "border") {
|
|
233 |
+ |
derived.push(("border-strong".into(), darken(border, 10.0)));
|
|
234 |
+ |
}
|
|
235 |
+ |
|
|
236 |
+ |
for (token, rgb) in derived {
|
|
237 |
+ |
intents.insert(token, rgb.to_hex());
|
|
238 |
+ |
}
|
|
239 |
+ |
|
|
240 |
+ |
SemanticTokens { meta: theme.meta.clone(), intents }
|
|
241 |
+ |
}
|
|
242 |
+ |
|
|
243 |
+ |
/// Emit the resolved intent layer as CSS declarations (no selector), one
|
|
244 |
+ |
/// ` --token: #hex;` line each, in deterministic (BTreeMap) order.
|
|
245 |
+ |
pub fn intent_css_declarations(tokens: &SemanticTokens) -> String {
|
|
246 |
+ |
let mut out = String::new();
|
|
247 |
+ |
for (token, hex) in &tokens.intents {
|
|
248 |
+ |
out.push_str(" --");
|
|
249 |
+ |
out.push_str(token);
|
|
250 |
+ |
out.push_str(": ");
|
|
251 |
+ |
out.push_str(hex);
|
|
252 |
+ |
out.push_str(";\n");
|
|
253 |
+ |
}
|
|
254 |
+ |
out
|
|
255 |
+ |
}
|
|
256 |
+ |
|
|
257 |
+ |
/// Emit the resolved intent layer as a `:root { … }` block — the single TOML →
|
|
258 |
+ |
/// CSS mapping every web surface injects.
|
|
259 |
+ |
pub fn intent_css_vars(tokens: &SemanticTokens) -> String {
|
|
260 |
+ |
format!(":root {{\n{}}}\n", intent_css_declarations(tokens))
|
|
261 |
+ |
}
|
|
262 |
+ |
|
|
263 |
+ |
// ============================================================================
|
|
264 |
+ |
// Loading / parsing
|
|
265 |
+ |
// ============================================================================
|
|
266 |
+ |
|
| 48 |
267 |
|
/// Validate a theme ID contains only safe characters (alphanumeric, hyphens, underscores).
|
| 49 |
268 |
|
pub fn validate_theme_id(id: &str) -> Result<(), String> {
|
| 50 |
269 |
|
if !id
|
| 56 |
275 |
|
Ok(())
|
| 57 |
276 |
|
}
|
| 58 |
277 |
|
|
| 59 |
|
- |
/// Parse the `[meta]` section from a TOML table into `ThemeMeta`.
|
|
278 |
+ |
/// Parse the `[meta]` section into `ThemeMeta`.
|
| 60 |
279 |
|
///
|
| 61 |
280 |
|
/// Falls back to the file ID as the name and `"dark"` as the variant.
|
| 62 |
281 |
|
pub fn parse_meta(id: &str, table: &toml::Table, is_custom: bool) -> ThemeMeta {
|
| 72 |
291 |
|
.unwrap_or("dark")
|
| 73 |
292 |
|
.to_string();
|
| 74 |
293 |
|
|
| 75 |
|
- |
ThemeMeta {
|
| 76 |
|
- |
id: id.to_string(),
|
| 77 |
|
- |
name,
|
| 78 |
|
- |
variant,
|
| 79 |
|
- |
is_custom,
|
| 80 |
|
- |
}
|
|
294 |
+ |
ThemeMeta { id: id.to_string(), name, variant, is_custom }
|
| 81 |
295 |
|
}
|
| 82 |
296 |
|
|
| 83 |
|
- |
/// Extract color sections (background, foreground, accent, border) from a TOML
|
| 84 |
|
- |
/// table into a flat `HashMap` with keys like `"background.primary"`.
|
|
297 |
+ |
/// Extract the intent color sections into a flat `HashMap` with dotted keys
|
|
298 |
+ |
/// like `"surface.page"`, `"status.danger"`, `"category.one"`.
|
| 85 |
299 |
|
pub fn extract_colors(table: &toml::Table) -> HashMap<String, String> {
|
| 86 |
300 |
|
let mut colors = HashMap::new();
|
| 87 |
|
- |
for section in &["background", "foreground", "accent", "border"] {
|
|
301 |
+ |
for section in COLOR_SECTIONS {
|
| 88 |
302 |
|
if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) {
|
| 89 |
303 |
|
for (key, val) in sect {
|
| 90 |
304 |
|
if let Some(color) = val.as_str() {
|
| 161 |
375 |
|
}
|
| 162 |
376 |
|
|
| 163 |
377 |
|
/// Parse a complete theme (metadata + colors) from raw TOML content, with no
|
| 164 |
|
- |
/// filesystem access.
|
| 165 |
|
- |
///
|
| 166 |
|
- |
/// For callers that embed themes at compile time (e.g. the MNW server bundles
|
| 167 |
|
- |
/// `shared/themes/*.toml` into the binary) rather than reading a directory.
|
| 168 |
|
- |
/// Keeps TOML parsing inside this crate so consumers need only `theme_common`.
|
|
378 |
+ |
/// filesystem access. For callers that embed themes at compile time.
|
| 169 |
379 |
|
pub fn parse_theme_str(id: &str, content: &str, is_custom: bool) -> Result<ThemeColors, String> {
|
| 170 |
380 |
|
validate_theme_id(id)?;
|
| 171 |
381 |
|
let table: toml::Table = content
|
| 196 |
406 |
|
Ok(ThemeColors { meta, colors })
|
| 197 |
407 |
|
}
|
| 198 |
408 |
|
|
|
409 |
+ |
/// Load a theme and resolve it to the full intent token set in one step.
|
|
410 |
+ |
pub fn load_semantic(dirs: &[(PathBuf, bool)], id: &str) -> Result<SemanticTokens, String> {
|
|
411 |
+ |
Ok(resolve(&load_theme(dirs, id)?))
|
|
412 |
+ |
}
|
|
413 |
+ |
|
| 199 |
414 |
|
/// Import a theme TOML file into the custom themes directory.
|
| 200 |
415 |
|
///
|
| 201 |
|
- |
/// Validates that the file is parseable TOML with the expected color sections,
|
| 202 |
|
- |
/// then copies it to `custom_dir/{id}.toml` where `id` is the file stem.
|
| 203 |
|
- |
/// Creates `custom_dir` if it doesn't exist. Returns the theme metadata.
|
|
416 |
+ |
/// Validates that the file is parseable TOML with at least one intent color
|
|
417 |
+ |
/// section, then copies it to `custom_dir/{id}.toml`. Returns the theme metadata.
|
| 204 |
418 |
|
pub fn import_theme(source_path: &Path, custom_dir: &Path) -> Result<ThemeMeta, String> {
|
| 205 |
419 |
|
let content = std::fs::read_to_string(source_path)
|
| 206 |
420 |
|
.map_err(|e| format!("Failed to read {}: {}", source_path.display(), e))?;
|
| 209 |
423 |
|
.parse()
|
| 210 |
424 |
|
.map_err(|e| format!("Invalid TOML: {}", e))?;
|
| 211 |
425 |
|
|
| 212 |
|
- |
// Verify it has at least one color section
|
| 213 |
|
- |
let has_colors = ["background", "foreground", "accent", "border"]
|
|
426 |
+ |
let has_colors = COLOR_SECTIONS
|
| 214 |
427 |
|
.iter()
|
| 215 |
428 |
|
.any(|s| table.get(*s).and_then(|v| v.as_table()).is_some());
|
| 216 |
429 |
|
if !has_colors {
|
| 217 |
|
- |
return Err("Theme file must have at least one color section (background, foreground, accent, or border)".to_string());
|
|
430 |
+ |
return Err(format!(
|
|
431 |
+ |
"Theme file must have at least one color section ({})",
|
|
432 |
+ |
COLOR_SECTIONS.join(", ")
|
|
433 |
+ |
));
|
| 218 |
434 |
|
}
|
| 219 |
435 |
|
|
| 220 |
436 |
|
let id = source_path
|
| 237 |
453 |
|
/// Delete a custom theme by ID.
|
| 238 |
454 |
|
///
|
| 239 |
455 |
|
/// Only operates on `custom_dir` — bundled themes are not deletable through
|
| 240 |
|
- |
/// this entry point. Returns `Err` if the ID is invalid, the file does not
|
| 241 |
|
- |
/// exist, or the underlying remove fails.
|
|
456 |
+ |
/// this entry point.
|
| 242 |
457 |
|
pub fn delete_theme(custom_dir: &Path, id: &str) -> Result<(), String> {
|
| 243 |
458 |
|
validate_theme_id(id)?;
|
| 244 |
459 |
|
|
| 251 |
466 |
|
.map_err(|e| format!("Failed to delete {}: {}", path.display(), e))
|
| 252 |
467 |
|
}
|
| 253 |
468 |
|
|
| 254 |
|
- |
/// A four-color palette for preview chips, swatches, etc.
|
| 255 |
|
- |
///
|
| 256 |
|
- |
/// Smaller than `ThemeColors`: only the `primary` value from each of the four
|
| 257 |
|
- |
/// canonical sections, so callers rendering a list of theme thumbnails don't
|
| 258 |
|
- |
/// need to allocate a full `HashMap` per row.
|
|
469 |
+ |
/// A four-color preview for theme thumbnails: the representative swatch from
|
|
470 |
+ |
/// each of the principal roles.
|
| 259 |
471 |
|
#[derive(Debug, Clone, Serialize)]
|
| 260 |
472 |
|
#[serde(rename_all = "camelCase")]
|
| 261 |
473 |
|
pub struct ThemePreview {
|
| 262 |
474 |
|
pub meta: ThemeMeta,
|
|
475 |
+ |
/// Page background (`surface.page`).
|
| 263 |
476 |
|
pub background: Option<String>,
|
|
477 |
+ |
/// Body text (`content.primary`).
|
| 264 |
478 |
|
pub foreground: Option<String>,
|
|
479 |
+ |
/// Brand/interactive color (`action.primary`).
|
| 265 |
480 |
|
pub accent: Option<String>,
|
|
481 |
+ |
/// Divider/outline color (`line.border`).
|
| 266 |
482 |
|
pub border: Option<String>,
|
| 267 |
483 |
|
}
|
| 268 |
484 |
|
|
| 269 |
|
- |
fn primary_color(table: &toml::Table, section: &str) -> Option<String> {
|
|
485 |
+ |
fn color_at(table: &toml::Table, section: &str, key: &str) -> Option<String> {
|
| 270 |
486 |
|
table
|
| 271 |
487 |
|
.get(section)
|
| 272 |
488 |
|
.and_then(|s| s.as_table())
|
| 273 |
|
- |
.and_then(|s| s.get("primary"))
|
|
489 |
+ |
.and_then(|s| s.get(key))
|
| 274 |
490 |
|
.and_then(|v| v.as_str())
|
| 275 |
491 |
|
.map(|s| s.to_string())
|
| 276 |
492 |
|
}
|
| 277 |
493 |
|
|
| 278 |
|
- |
/// Load just the primary colors for a theme — for UI previews / thumbnails.
|
|
494 |
+ |
/// Load just the preview swatches for a theme — for UI thumbnails.
|
| 279 |
495 |
|
pub fn load_theme_preview(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemePreview, String> {
|
| 280 |
496 |
|
validate_theme_id(id)?;
|
| 281 |
497 |
|
|
| 291 |
507 |
|
|
| 292 |
508 |
|
Ok(ThemePreview {
|
| 293 |
509 |
|
meta: parse_meta(id, &table, is_custom),
|
| 294 |
|
- |
background: primary_color(&table, "background"),
|
| 295 |
|
- |
foreground: primary_color(&table, "foreground"),
|
| 296 |
|
- |
accent: primary_color(&table, "accent"),
|
| 297 |
|
- |
border: primary_color(&table, "border"),
|
|
510 |
+ |
background: color_at(&table, "surface", "page"),
|
|
511 |
+ |
foreground: color_at(&table, "content", "primary"),
|
|
512 |
+ |
accent: color_at(&table, "action", "primary"),
|
|
513 |
+ |
border: color_at(&table, "line", "border"),
|
| 298 |
514 |
|
})
|
| 299 |
515 |
|
}
|
| 300 |
516 |
|
|
| 301 |
517 |
|
/// Export a theme to a user-chosen path.
|
| 302 |
|
- |
///
|
| 303 |
|
- |
/// Finds the theme by ID in the given directories and copies the TOML file
|
| 304 |
|
- |
/// to `dest_path`.
|
| 305 |
518 |
|
pub fn export_theme(dirs: &[(PathBuf, bool)], id: &str, dest_path: &Path) -> Result<(), String> {
|
| 306 |
519 |
|
validate_theme_id(id)?;
|
| 307 |
520 |
|
|
| 314 |
527 |
|
Ok(())
|
| 315 |
528 |
|
}
|
| 316 |
529 |
|
|
| 317 |
|
- |
/// The canonical TOML-key -> CSS-custom-property mapping for the primitive layer.
|
| 318 |
|
- |
///
|
| 319 |
|
- |
/// This is the single source of truth for how a theme-common palette becomes CSS
|
| 320 |
|
- |
/// variables. Every web surface (MNW server-rendered pages, and eventually
|
| 321 |
|
- |
/// GoingsOn — which today hand-maintains an equivalent `COLOR_MAP` in JS) emits
|
| 322 |
|
- |
/// the *same* variable names from this table, so adding a token is one edit here.
|
| 323 |
|
- |
///
|
| 324 |
|
- |
/// The 14 primitive tokens: 4 backgrounds, 3 foregrounds, 6 accents, 1 border.
|
| 325 |
|
- |
/// Apps derive their semantic roles (`--surface`, `--danger`, diff/health, …)
|
| 326 |
|
- |
/// from these in their own base CSS; the crate stays a pure palette.
|
| 327 |
|
- |
pub const PRIMITIVE_VARS: &[(&str, &str)] = &[
|
| 328 |
|
- |
("background.primary", "--bg-primary"),
|
| 329 |
|
- |
("background.secondary", "--bg-secondary"),
|
| 330 |
|
- |
("background.tertiary", "--bg-tertiary"),
|
| 331 |
|
- |
("background.surface", "--bg-surface"),
|
| 332 |
|
- |
("foreground.primary", "--fg-primary"),
|
| 333 |
|
- |
("foreground.secondary", "--fg-secondary"),
|
| 334 |
|
- |
("foreground.muted", "--fg-muted"),
|
| 335 |
|
- |
("accent.red", "--accent-red"),
|
| 336 |
|
- |
("accent.green", "--accent-green"),
|
| 337 |
|
- |
("accent.blue", "--accent-blue"),
|
| 338 |
|
- |
("accent.yellow", "--accent-yellow"),
|
| 339 |
|
- |
("accent.purple", "--accent-purple"),
|
| 340 |
|
- |
("accent.cyan", "--accent-cyan"),
|
| 341 |
|
- |
("border.default", "--border-default"),
|
| 342 |
|
- |
];
|
| 343 |
|
- |
|
| 344 |
|
- |
/// Emit the primitive layer as CSS declarations (no selector wrapper).
|
| 345 |
|
- |
///
|
| 346 |
|
- |
/// One ` --name: value;` line per token that the theme actually defines, in the
|
| 347 |
|
- |
/// canonical [`PRIMITIVE_VARS`] order. Missing tokens are skipped rather than
|
| 348 |
|
- |
/// emitted empty, mirroring how the desktop apps apply only present keys. The
|
| 349 |
|
- |
/// caller chooses the selector — use [`to_css_vars`] for a ready `:root` block,
|
| 350 |
|
- |
/// or wrap these declarations in any scope (e.g. a creator canvas class).
|
| 351 |
|
- |
pub fn to_css_declarations(theme: &ThemeColors) -> String {
|
| 352 |
|
- |
let mut out = String::new();
|
| 353 |
|
- |
for (toml_key, css_var) in PRIMITIVE_VARS {
|
| 354 |
|
- |
if let Some(value) = theme.colors.get(*toml_key) {
|
| 355 |
|
- |
out.push_str(" ");
|
| 356 |
|
- |
out.push_str(css_var);
|
| 357 |
|
- |
out.push_str(": ");
|
| 358 |
|
- |
out.push_str(value);
|
| 359 |
|
- |
out.push_str(";\n");
|
| 360 |
|
- |
}
|
| 361 |
|
- |
}
|
| 362 |
|
- |
out
|
| 363 |
|
- |
}
|
| 364 |
|
- |
|
| 365 |
|
- |
/// Emit the primitive layer as a CSS `:root { … }` block.
|
| 366 |
|
- |
///
|
| 367 |
|
- |
/// This is the single TOML -> CSS mapping for the web. Inject the result into a
|
| 368 |
|
- |
/// page `<head>` (or serve it as a stylesheet) to apply a theme's primitive
|
| 369 |
|
- |
/// palette; the app's semantic layer derives from these variables.
|
| 370 |
|
- |
pub fn to_css_vars(theme: &ThemeColors) -> String {
|
| 371 |
|
- |
format!(":root {{\n{}}}\n", to_css_declarations(theme))
|
| 372 |
|
- |
}
|
| 373 |
|
- |
|
| 374 |
530 |
|
/// Construct a dev fallback theme directory path.
|
| 375 |
531 |
|
///
|
| 376 |
532 |
|
/// Given a `CARGO_MANIFEST_DIR`, walks up `levels` parent directories and
|
| 377 |
|
- |
/// appends `"themes"`. Returns the path if the directory exists.
|
|
533 |
+ |
/// appends `MNW/shared/themes`. Returns the path if the directory exists.
|
| 378 |
534 |
|
pub fn dev_themes_dir(manifest_dir: &Path, levels: usize) -> Option<PathBuf> {
|
| 379 |
535 |
|
let mut path = manifest_dir.to_path_buf();
|
| 380 |
536 |
|
for _ in 0..levels {
|
| 393 |
549 |
|
use super::*;
|
| 394 |
550 |
|
use std::fs;
|
| 395 |
551 |
|
|