- model.rs: 全部数据结构(Deck/Slide/Element/Style/Theme/AiCfg/LibItem/PageTemplate/ChatMessage/Op/RichSegment) 所有结构体 derive Serialize/Deserialize/TS,自动生成 TS 类型到 bindings/types.ts - color.rs: 颜色解析纯函数(resolve_color/resolve_bg/is_dark_bg/shade/hex_to_rgb) 对应 store.ts 的颜色逻辑,23 个单元测试全部通过 - lib.rs: 新增 app_info IPC 命令 - ts-rs 自动生成的 bindings/types.ts 与手写 types.ts 类型一致
213 lines
6.0 KiB
Rust
213 lines
6.0 KiB
Rust
/* =====================================================================
|
|
* color.rs — 颜色/背景解析纯函数(对应 store.ts 的颜色函数)
|
|
*
|
|
* 这些函数供 Rust 端的渲染/序列化使用(B2 store 迁移时调用)。
|
|
* 当前阶段保持与 TS 版本完全一致的逻辑。
|
|
* ===================================================================== */
|
|
|
|
pub fn hex_to_rgb(hex: &str) -> Option<(u8, u8, u8)> {
|
|
let c = hex.trim_start_matches('#');
|
|
let full = if c.len() == 3 {
|
|
format!(
|
|
"{}{}{}{}{}{}",
|
|
&c[0..1],
|
|
&c[0..1],
|
|
&c[1..2],
|
|
&c[1..2],
|
|
&c[2..3],
|
|
&c[2..3]
|
|
)
|
|
} else {
|
|
c.to_string()
|
|
};
|
|
if full.len() < 6 {
|
|
return None;
|
|
}
|
|
let r = u8::from_str_radix(&full[0..2], 16).ok()?;
|
|
let g = u8::from_str_radix(&full[2..4], 16).ok()?;
|
|
let b = u8::from_str_radix(&full[4..6], 16).ok()?;
|
|
Some((r, g, b))
|
|
}
|
|
|
|
pub fn is_dark_hex(hex: &str) -> bool {
|
|
match hex_to_rgb(hex) {
|
|
Some((r, g, b)) => (0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64) < 145.0,
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
pub fn shade(hex: &str, pct: f64) -> String {
|
|
let (r, g, b) = match hex_to_rgb(hex) {
|
|
Some(rgb) => rgb,
|
|
None => return hex.to_string(),
|
|
};
|
|
let f = if pct < 0.0 { 0.0 } else { 255.0 };
|
|
let p = pct.abs() / 100.0;
|
|
let nr = ((f - r as f64) * p + r as f64).round() as u8;
|
|
let ng = ((f - g as f64) * p + g as f64).round() as u8;
|
|
let nb = ((f - b as f64) * p + b as f64).round() as u8;
|
|
format!("#{:02x}{:02x}{:02x}", nr, ng, nb)
|
|
}
|
|
|
|
pub fn is_dark_bg(bg: &str) -> bool {
|
|
if bg.is_empty() {
|
|
return false;
|
|
}
|
|
if bg.starts_with('#') {
|
|
return is_dark_hex(bg);
|
|
}
|
|
if bg == "primary" || bg == "accent" {
|
|
return true;
|
|
}
|
|
if bg.starts_with("g-") {
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
pub fn is_valid_hex(s: &str) -> bool {
|
|
s.starts_with('#')
|
|
&& s.len() >= 4
|
|
&& s[1..].chars().all(|c| c.is_ascii_hexdigit())
|
|
}
|
|
|
|
/// 颜色解析:dark=true 时深色文字键反相为浅色
|
|
pub fn resolve_color(key: &str, dark: bool, theme: &crate::model::Theme) -> String {
|
|
if key.is_empty() {
|
|
return String::new();
|
|
}
|
|
if key.starts_with('#') {
|
|
if !is_valid_hex(key) {
|
|
return if dark {
|
|
"#ffffff".into()
|
|
} else {
|
|
theme.text.clone()
|
|
};
|
|
}
|
|
if dark && is_dark_hex(key) {
|
|
return "#ffffff".into();
|
|
}
|
|
return key.into();
|
|
}
|
|
if dark {
|
|
match key {
|
|
"text" => "#ffffff".into(),
|
|
"muted" => "rgba(255,255,255,0.72)".into(),
|
|
"primary" => "#ffffff".into(),
|
|
"accent" => theme.accent.clone(),
|
|
_ => "#ffffff".into(),
|
|
}
|
|
} else {
|
|
match key {
|
|
"primary" => theme.primary.clone(),
|
|
"accent" => theme.accent.clone(),
|
|
"text" => theme.text.clone(),
|
|
"muted" => theme.muted.clone(),
|
|
_ => theme.text.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 背景解析:渐变键 → CSS gradient
|
|
pub fn resolve_bg(key: &str, theme: &crate::model::Theme) -> String {
|
|
if key.is_empty() {
|
|
return "#ffffff".into();
|
|
}
|
|
if key.starts_with('#') {
|
|
return if is_valid_hex(key) {
|
|
key.into()
|
|
} else {
|
|
"#ffffff".into()
|
|
};
|
|
}
|
|
match key {
|
|
"g-primary" => format!(
|
|
"linear-gradient(135deg, {} 0%, {} 100%)",
|
|
theme.primary, theme.accent
|
|
),
|
|
"g-deep" => format!(
|
|
"linear-gradient(160deg, {} 0%, {} 100%)",
|
|
shade(&theme.primary, -28.0),
|
|
theme.primary
|
|
),
|
|
"g-soft" => format!(
|
|
"linear-gradient(135deg, {} 0%, {} 100%)",
|
|
theme.panel.clone(),
|
|
theme.bg.clone()
|
|
),
|
|
"primary" => theme.primary.clone(),
|
|
"accent" => theme.accent.clone(),
|
|
"panel" => theme.panel.clone(),
|
|
"bg" => theme.bg.clone(),
|
|
_ => "#ffffff".into(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_hex_to_rgb() {
|
|
assert_eq!(hex_to_rgb("#4f46e5"), Some((79, 70, 229)));
|
|
assert_eq!(hex_to_rgb("#fff"), Some((255, 255, 255)));
|
|
assert_eq!(hex_to_rgb("invalid"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_dark_hex() {
|
|
assert!(is_dark_hex("#0f172a")); // 深色
|
|
assert!(is_dark_hex("#4f46e5")); // 青蓝偏深
|
|
assert!(!is_dark_hex("#ffffff")); // 白色
|
|
assert!(!is_dark_hex("#f59e0b")); // 琥珀亮色
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_dark_bg() {
|
|
assert!(is_dark_bg("primary"));
|
|
assert!(is_dark_bg("g-deep"));
|
|
assert!(!is_dark_bg("bg"));
|
|
assert!(!is_dark_bg("panel"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_shade() {
|
|
let result = shade("#000000", 50.0);
|
|
assert_eq!(result, "#808080"); // 黑色提亮 50% → 灰色
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_color_light() {
|
|
use crate::model::Theme;
|
|
let theme = Theme {
|
|
name: "test".into(),
|
|
primary: "#4f46e5".into(),
|
|
accent: "#06b6d4".into(),
|
|
bg: "#ffffff".into(),
|
|
panel: "#f8fafc".into(),
|
|
text: "#1e293b".into(),
|
|
muted: "#64748b".into(),
|
|
};
|
|
assert_eq!(resolve_color("primary", false, &theme), "#4f46e5");
|
|
assert_eq!(resolve_color("text", false, &theme), "#1e293b");
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_color_dark() {
|
|
use crate::model::Theme;
|
|
let theme = Theme {
|
|
name: "test".into(),
|
|
primary: "#4f46e5".into(),
|
|
accent: "#06b6d4".into(),
|
|
bg: "#ffffff".into(),
|
|
panel: "#f8fafc".into(),
|
|
text: "#1e293b".into(),
|
|
muted: "#64748b".into(),
|
|
};
|
|
// 深色背景下 text 键 → 白色
|
|
assert_eq!(resolve_color("text", true, &theme), "#ffffff");
|
|
// primary 键在深色背景下也 → 白色
|
|
assert_eq!(resolve_color("primary", true, &theme), "#ffffff");
|
|
}
|
|
}
|