#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThemePreset {
pub load_glyphs: Vec<String>,
pub pulse_style: String,
pub band_tints: Vec<String>,
pub pulse_palette: Vec<String>,
pub label_color: String,
pub separator: String,
pub separator_color: String,
pub hud_seam: String,
}
pub const THEME_KEYS: &[(&str, &str)] = &[
("cpu", "load_glyphs"),
("pulse", "pulse_style"),
("color", "band_tints"),
("color", "pulse_palette"),
("color", "label_color"),
("color", "separator"),
("color", "separator_color"),
("color", "hud_seam"),
];
const CATALOG: &[(&str, &str)] = &[
("calm", "차가운 blue-grey + 테라코타 호흡 (기본)"),
("mono", "무채색, 제로 색상"),
("vivid", "신호등 색 + 블록 글리프"),
("ember", "따뜻한 앰버/테라코타 단색"),
("emoji", "이모지 표정 램프 (2칸 폭)"),
];
fn to_owned(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
fn calm_preset() -> ThemePreset {
ThemePreset {
load_glyphs: to_owned(&["○", "▁", "▄", "▆", "◆"]),
pulse_style: "calm".to_string(),
band_tints: to_owned(&["#5a6878", "#6d8296", "#86a0b4", "#9fbfce", "#b87848"]),
pulse_palette: to_owned(&["#b87848", "#7a5030"]),
label_color: "#6b7280".to_string(),
separator: " · ".to_string(),
separator_color: "#3b4048".to_string(),
hud_seam: "│".to_string(),
}
}
fn mono_preset() -> ThemePreset {
ThemePreset {
load_glyphs: to_owned(&["○", "▁", "▄", "▆", "◆"]),
pulse_style: "calm".to_string(),
band_tints: to_owned(&["#636363", "#7e7e7e", "#9c9c9c", "#bdbdbd", "#e8e8e8"]),
pulse_palette: to_owned(&["#e8e8e8", "#9c9c9c"]),
label_color: "#6b7280".to_string(),
separator: " · ".to_string(),
separator_color: "#3b4048".to_string(),
hud_seam: "│".to_string(),
}
}
fn vivid_preset() -> ThemePreset {
ThemePreset {
load_glyphs: to_owned(&["░", "▒", "▓", "█", "█"]),
pulse_style: "calm".to_string(),
band_tints: to_owned(&["#2f9150", "#3fb083", "#cda23e", "#f0a24e", "#e34a3a"]),
pulse_palette: to_owned(&["#e34a3a", "#bf4135"]),
label_color: "#6b7280".to_string(),
separator: " · ".to_string(),
separator_color: "#3b4048".to_string(),
hud_seam: "│".to_string(),
}
}
fn ember_preset() -> ThemePreset {
ThemePreset {
load_glyphs: to_owned(&["·", "∙", "•", "●", "◉"]),
pulse_style: "calm".to_string(),
band_tints: to_owned(&["#7a6450", "#96714f", "#b08355", "#c79a63", "#cf5a48"]),
pulse_palette: to_owned(&["#cf5a48", "#a8483a"]),
label_color: "#7a6f63".to_string(),
separator: " · ".to_string(),
separator_color: "#4a4239".to_string(),
hud_seam: "│".to_string(),
}
}
fn emoji_preset() -> ThemePreset {
ThemePreset {
load_glyphs: to_owned(&["😌", "🙂", "😅", "🥵", "🔥"]),
pulse_style: "calm".to_string(),
band_tints: to_owned(&["#6e7d92", "#86978f", "#a39a78", "#c6a35c", "#e0683c"]),
pulse_palette: to_owned(&["#e0683c", "#a04528"]),
label_color: "#6b7280".to_string(),
separator: " · ".to_string(),
separator_color: "#383d45".to_string(),
hud_seam: "│".to_string(),
}
}
pub fn preset(name: &str) -> Option<ThemePreset> {
match name {
"calm" => Some(calm_preset()),
"mono" => Some(mono_preset()),
"vivid" => Some(vivid_preset()),
"ember" => Some(ember_preset()),
"emoji" => Some(emoji_preset()),
_ => None,
}
}
pub fn catalog() -> &'static [(&'static str, &'static str)] {
CATALOG
}
pub fn is_known(name: &str) -> bool {
preset(name).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
#[test]
fn preset_calm_matches_default_config() {
let calm = preset("calm").expect("calm은 항상 존재");
let default = Config::default();
assert_eq!(calm.load_glyphs, default.cpu.load_glyphs, "load_glyphs");
assert_eq!(calm.pulse_style, default.pulse.pulse_style, "pulse_style");
assert_eq!(calm.band_tints, default.color.band_tints, "band_tints");
assert_eq!(
calm.pulse_palette, default.color.pulse_palette,
"pulse_palette"
);
assert_eq!(calm.label_color, default.color.label_color, "label_color");
assert_eq!(calm.separator, default.color.separator, "separator");
assert_eq!(
calm.separator_color, default.color.separator_color,
"separator_color"
);
assert_eq!(calm.hud_seam, default.color.hud_seam, "hud_seam");
}
#[test]
fn theme_keys_match_preset_fields() {
assert_eq!(
THEME_KEYS.len(),
8,
"THEME_KEYS는 ThemePreset 필드와 1:1(8개)"
);
let valid_sections = ["cpu", "pulse", "color"];
for (section, key) in THEME_KEYS {
assert!(
valid_sections.contains(section),
"알 수 없는 섹션: {section}.{key}"
);
}
}
#[test]
fn all_presets_have_5_band_tints_and_glyphs() {
for (name, _) in catalog() {
let p = preset(name).expect("catalog 이름은 항상 프리셋 존재");
assert_eq!(p.load_glyphs.len(), 5, "{name} load_glyphs 길이");
assert_eq!(p.band_tints.len(), 5, "{name} band_tints 길이");
assert_eq!(p.pulse_palette.len(), 2, "{name} pulse_palette 길이");
}
}
#[test]
fn all_preset_hex_are_valid() {
let is_valid_hex = |s: &str| {
s.len() == 7 && s.starts_with('#') && s[1..].chars().all(|c| c.is_ascii_hexdigit())
};
for (name, _) in catalog() {
let p = preset(name).expect("catalog 이름은 항상 프리셋 존재");
for hex in p.band_tints.iter().chain(p.pulse_palette.iter()) {
assert!(is_valid_hex(hex), "{name} 잘못된 hex: {hex}");
}
assert!(is_valid_hex(&p.label_color), "{name} label_color");
assert!(is_valid_hex(&p.separator_color), "{name} separator_color");
}
}
#[test]
fn emoji_glyphs_are_single_char_width_two() {
let emoji = preset("emoji").expect("emoji 프리셋 존재");
for glyph in &emoji.load_glyphs {
assert_eq!(
glyph.chars().count(),
1,
"emoji 글리프는 단일 코드포인트: {glyph}"
);
let code = glyph.chars().next().expect("비어있지 않음") as u32;
assert!(
(0x1F300..=0x1FAFF).contains(&code),
"emoji 글리프 {glyph}(U+{code:X})는 2칸 처리 범위 밖"
);
}
}
#[test]
fn catalog_matches_is_known() {
for (name, _) in catalog() {
assert!(is_known(name), "catalog 이름 {name}은 is_known 통과해야");
}
assert!(!is_known("nonexistent"), "미지 테마는 is_known false");
assert!(!is_known(""), "빈 문자열은 미지 테마");
}
#[test]
fn catalog_order_is_release_order() {
let names: Vec<&str> = catalog().iter().map(|(name, _)| *name).collect();
assert_eq!(names, vec!["calm", "mono", "vivid", "ember", "emoji"]);
}
}