use crate::config::Config;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ColorSpec {
pub r: u8,
pub g: u8,
pub b: u8,
}
pub fn pulse_gate(cpu_percent: f64, prev_on: bool, cfg: &Config) -> bool {
if prev_on {
cpu_percent >= cfg.pulse.pulse_off_threshold
} else {
cpu_percent >= cfg.pulse.pulse_on_threshold
}
}
pub fn band_index(cpu_percent: f64, cfg: &Config) -> usize {
let thresholds = cfg.cpu.emoji_thresholds;
if cpu_percent < thresholds[0] {
0
} else if cpu_percent < thresholds[1] {
1
} else if cpu_percent < thresholds[2] {
2
} else if cpu_percent < thresholds[3] {
3
} else {
4
}
}
pub fn band_tint(band: usize, cfg: &Config) -> ColorSpec {
cfg.color
.band_tints
.get(band)
.and_then(|hex| parse_hex(hex))
.unwrap_or(ColorSpec {
r: 0x6d,
g: 0x82,
b: 0x96,
})
}
pub fn pick_emoji(cpu_percent: f64, now_ms: u128, pulse_on: bool, cfg: &Config) -> String {
let band = band_index(cpu_percent, cfg);
const FALLBACK: [&str; 5] = ["○", "▁", "▄", "▆", "◆"];
let base = match cfg.cpu.load_glyphs.get(band) {
Some(glyph) if !glyph.is_empty() => glyph.clone(),
_ => FALLBACK[band.min(FALLBACK.len() - 1)].to_string(),
};
if pulse_on && cfg.pulse.pulse_style == "swap" && pulse_phase(now_ms, cfg) >= 0.5 {
return alt_glyph(&base);
}
base
}
fn alt_glyph(glyph: &str) -> String {
let alt = match glyph {
"◆" => "◇",
"◇" => "◆",
"●" => "○",
"○" => "●",
"◉" => "◎",
"◎" => "◉",
"█" => "░",
"░" => "█",
"▓" => "▒",
"▒" => "▓",
"▆" => "▂",
"▂" => "▆",
"▄" => "▁",
"▁" => "▄",
other => other,
};
alt.to_string()
}
pub const PULSE_STYLES: &[&str] = &["calm", "flash", "hue", "swap"];
pub fn is_known_pulse_style(name: &str) -> bool {
PULSE_STYLES.contains(&name)
}
pub fn pulse_color(
cpu_percent: f64,
now_ms: u128,
pulse_on: bool,
cfg: &Config,
) -> Option<ColorSpec> {
let _ = cpu_percent;
if !pulse_on {
return None;
}
let phase = pulse_phase(now_ms, cfg);
let start = palette_color(cfg, 0).unwrap_or(ColorSpec {
r: 0xb8,
g: 0x78,
b: 0x48,
});
let end = palette_color(cfg, 1).unwrap_or(ColorSpec {
r: 0x7a,
g: 0x50,
b: 0x30,
});
let tint = match cfg.pulse.pulse_style.as_str() {
"hue" | "swap" => hue_rotate(start, phase),
"flash" => luminance_breath(start, end, flash_wave(phase)),
_ => luminance_breath(start, end, calm_wave(phase)),
};
Some(tint)
}
fn calm_wave(phase: f64) -> f64 {
(f64::sin(2.0 * std::f64::consts::PI * phase) + 1.0) / 2.0
}
fn flash_wave(phase: f64) -> f64 {
calm_wave(phase).powf(2.2)
}
fn luminance_breath(start: ColorSpec, end: ColorSpec, wave: f64) -> ColorSpec {
ColorSpec {
r: lerp_channel(start.r, end.r, wave),
g: lerp_channel(start.g, end.g, wave),
b: lerp_channel(start.b, end.b, wave),
}
}
fn hue_rotate(base: ColorSpec, phase: f64) -> ColorSpec {
let (h, s, v) = rgb_to_hsv(base);
hsv_to_rgb(h + 360.0 * phase, s, v)
}
pub fn samples_per_period(cfg: &Config, refresh_interval_seconds: u64) -> u64 {
let interval = refresh_interval_seconds.max(1);
cfg.pulse.pulse_period_seconds / interval
}
fn pulse_phase(now_ms: u128, cfg: &Config) -> f64 {
let period_ms = cfg.pulse.pulse_period_seconds.max(1) as u128 * 1000;
let offset = (now_ms % period_ms) as f64;
offset / period_ms as f64
}
fn lerp_channel(start: u8, end: u8, t: f64) -> u8 {
let value = start as f64 + (end as f64 - start as f64) * t;
value.round().clamp(0.0, 255.0) as u8
}
fn palette_color(cfg: &Config, index: usize) -> Option<ColorSpec> {
let entry = cfg.color.pulse_palette.get(index)?;
parse_hex(entry)
}
pub fn parse_hex_pub(hex: &str) -> Option<ColorSpec> {
parse_hex(hex)
}
fn rgb_to_hsv(c: ColorSpec) -> (f64, f64, f64) {
let r = c.r as f64 / 255.0;
let g = c.g as f64 / 255.0;
let b = c.b as f64 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let delta = max - min;
let h = if delta == 0.0 {
0.0
} else if max == r {
60.0 * ((g - b) / delta).rem_euclid(6.0)
} else if max == g {
60.0 * (((b - r) / delta) + 2.0)
} else {
60.0 * (((r - g) / delta) + 4.0)
};
let s = if max == 0.0 { 0.0 } else { delta / max };
(h.rem_euclid(360.0), s, max)
}
fn hsv_to_rgb(h: f64, s: f64, v: f64) -> ColorSpec {
let h = h.rem_euclid(360.0);
let c = v * s;
let x = c * (1.0 - ((h / 60.0).rem_euclid(2.0) - 1.0).abs());
let m = v - c;
let (r1, g1, b1) = match (h / 60.0) as u32 {
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 to_u8 = |f: f64| ((f + m) * 255.0).round().clamp(0.0, 255.0) as u8;
ColorSpec {
r: to_u8(r1),
g: to_u8(g1),
b: to_u8(b1),
}
}
fn parse_hex(hex: &str) -> Option<ColorSpec> {
let trimmed = hex.trim().trim_start_matches('#');
if trimmed.len() != 6 {
return None;
}
let r = u8::from_str_radix(&trimmed[0..2], 16).ok()?;
let g = u8::from_str_radix(&trimmed[2..4], 16).ok()?;
let b = u8::from_str_radix(&trimmed[4..6], 16).ok()?;
Some(ColorSpec { r, g, b })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
const STATIC_NOW: u128 = 0;
#[test]
fn band_index_boundaries() {
let cfg = Config::default();
assert_eq!(band_index(24.9, &cfg), 0);
assert_eq!(band_index(25.0, &cfg), 1);
assert_eq!(band_index(49.9, &cfg), 1);
assert_eq!(band_index(50.0, &cfg), 2);
assert_eq!(band_index(74.9, &cfg), 2);
assert_eq!(band_index(75.0, &cfg), 3);
assert_eq!(band_index(89.9, &cfg), 3);
assert_eq!(band_index(90.0, &cfg), 4);
assert_eq!(band_index(100.0, &cfg), 4);
}
#[test]
fn pick_emoji_band_glyphs() {
let cfg = Config::default();
assert_eq!(pick_emoji(24.9, STATIC_NOW, false, &cfg), "○");
assert_eq!(pick_emoji(25.0, STATIC_NOW, false, &cfg), "▁");
assert_eq!(pick_emoji(60.0, STATIC_NOW, false, &cfg), "▄");
assert_eq!(pick_emoji(89.9, STATIC_NOW, false, &cfg), "▆");
assert_eq!(pick_emoji(90.0, STATIC_NOW, false, &cfg), "◆");
assert_eq!(pick_emoji(100.0, STATIC_NOW, false, &cfg), "◆");
}
#[test]
fn pick_emoji_glyph_is_stable_when_pulsing() {
let cfg = Config::default(); let early = pick_emoji(95.0, 1_000, true, &cfg);
let mid = pick_emoji(95.0, 15_000, true, &cfg);
let late = pick_emoji(95.0, 20_000, true, &cfg);
assert_eq!(early, "◆", "crit 밴드 글리프는 ◆");
assert_eq!(early, mid, "펄스 위상이 달라도 글리프는 고정(깜빡임 금지)");
assert_eq!(early, late, "펄스 위상이 달라도 글리프는 고정(깜빡임 금지)");
}
#[test]
fn pick_emoji_respects_custom_emoji_glyphs() {
let mut cfg = Config::default();
cfg.cpu.load_glyphs = vec![
"😌".to_string(),
"🙂".to_string(),
"😅".to_string(),
"🥵".to_string(),
"🔥".to_string(),
];
assert_eq!(pick_emoji(10.0, STATIC_NOW, false, &cfg), "😌");
assert_eq!(pick_emoji(95.0, STATIC_NOW, true, &cfg), "🔥");
}
#[test]
fn band_tint_maps_band_to_palette() {
let cfg = Config::default();
assert_eq!(
band_tint(0, &cfg),
ColorSpec {
r: 0x5a,
g: 0x68,
b: 0x78
}
);
assert_eq!(
band_tint(4, &cfg),
ColorSpec {
r: 0xb8,
g: 0x78,
b: 0x48
}
);
assert_ne!(band_tint(3, &cfg), band_tint(4, &cfg));
}
#[test]
fn pulse_gate_hysteresis() {
let cfg = Config::default(); assert!(!pulse_gate(88.0, false, &cfg));
assert!(pulse_gate(92.0, false, &cfg));
assert!(pulse_gate(85.0, true, &cfg));
assert!(!pulse_gate(78.0, true, &cfg));
}
#[test]
fn pulse_color_none_when_off() {
let cfg = Config::default();
assert_eq!(pulse_color(95.0, 1_234, false, &cfg), None);
}
#[test]
fn pulse_color_pure_same_now_same_spec() {
let cfg = Config::default();
let a = pulse_color(95.0, 1_500, true, &cfg);
let b = pulse_color(95.0, 1_500, true, &cfg);
assert!(a.is_some());
assert_eq!(a, b, "순수 함수: 같은 now → 같은 ColorSpec");
}
#[test]
fn pulse_color_varies_across_now() {
let cfg = Config::default();
let peak = pulse_color(95.0, 7_500, true, &cfg);
let trough = pulse_color(95.0, 22_500, true, &cfg);
assert!(peak.is_some() && trough.is_some());
assert_ne!(peak, trough, "펄스 틴트는 시각(위상)에 따라 변해야 함");
}
#[test]
fn pulse_color_breathes_between_terracotta_endpoints() {
let cfg = Config::default(); let high = pulse_color(95.0, 22_500, true, &cfg).expect("펄스 ON 틴트");
assert_eq!(
high,
ColorSpec {
r: 0xb8,
g: 0x78,
b: 0x48
},
"wave=0 끝점은 high 테라코타여야 함"
);
let low = pulse_color(95.0, 7_500, true, &cfg).expect("펄스 ON 틴트");
assert_eq!(
low,
ColorSpec {
r: 0x7a,
g: 0x50,
b: 0x30
},
"wave=1 끝점은 low dim 테라코타여야 함"
);
}
#[test]
fn pulse_color_has_no_hue_shift() {
let cfg = Config::default();
for &now in &[7_500u128, 15_000, 22_500, 1_000] {
let tint = pulse_color(95.0, now, true, &cfg).expect("펄스 ON 틴트");
assert!(
tint.r > tint.g && tint.g > tint.b,
"테라코타 톤(R>G>B) 유지: {tint:?}"
);
}
}
#[test]
fn samples_per_period_default_is_six() {
let cfg = Config::default(); assert_eq!(samples_per_period(&cfg, cfg.refresh.interval_seconds), 6);
assert!(
samples_per_period(&cfg, cfg.refresh.interval_seconds) >= 6,
"불변식: samples_per_period ≥ 6 (지각성)"
);
}
#[test]
fn samples_per_period_guards_zero_interval() {
let cfg = Config::default(); assert_eq!(samples_per_period(&cfg, 0), 30);
}
#[test]
fn rgb_hsv_roundtrip_within_tolerance() {
for c in [
ColorSpec {
r: 0xff,
g: 0x2b,
b: 0xd0,
},
ColorSpec {
r: 0x2f,
g: 0xd3,
b: 0x6b,
},
ColorSpec {
r: 0xb8,
g: 0x78,
b: 0x48,
},
ColorSpec {
r: 0x00,
g: 0x00,
b: 0x00,
},
ColorSpec {
r: 0xff,
g: 0xff,
b: 0xff,
},
] {
let (h, s, v) = rgb_to_hsv(c);
let back = hsv_to_rgb(h, s, v);
assert!(
(back.r as i16 - c.r as i16).abs() <= 2
&& (back.g as i16 - c.g as i16).abs() <= 2
&& (back.b as i16 - c.b as i16).abs() <= 2,
"라운드트립 오차 초과: {c:?} -> {back:?}"
);
}
}
#[test]
fn hsv_to_rgb_rotates_hue() {
let red = hsv_to_rgb(0.0, 1.0, 1.0);
let green = hsv_to_rgb(120.0, 1.0, 1.0);
let blue = hsv_to_rgb(240.0, 1.0, 1.0);
assert_eq!(red, ColorSpec { r: 255, g: 0, b: 0 });
assert_eq!(green, ColorSpec { r: 0, g: 255, b: 0 });
assert_eq!(blue, ColorSpec { r: 0, g: 0, b: 255 });
}
#[test]
fn pulse_color_flash_sharpens_midtone() {
let mut cfg = Config::default();
cfg.pulse.pulse_style = "flash".to_string();
let mut calm = Config::default();
calm.pulse.pulse_style = "calm".to_string();
assert_eq!(
pulse_color(95.0, 22_500, true, &cfg),
pulse_color(95.0, 22_500, true, &calm),
"flash와 calm은 high 끝점(phase=0.75)에서 동일"
);
assert_eq!(
pulse_color(95.0, 7_500, true, &cfg),
pulse_color(95.0, 7_500, true, &calm),
"flash와 calm은 low 끝점(phase=0.25)에서 동일"
);
assert_ne!(
pulse_color(95.0, 15_000, true, &cfg),
pulse_color(95.0, 15_000, true, &calm),
"flash는 중간 위상에서 calm과 다른(더 가파른) 틴트"
);
}
#[test]
fn pulse_color_hue_rotates() {
let mut cfg = Config::default();
cfg.pulse.pulse_style = "hue".to_string();
let base = pulse_color(95.0, 0, true, &cfg).expect("hue 틴트");
assert!(
(base.r as i16 - 0xb8).abs() <= 2
&& (base.g as i16 - 0x78).abs() <= 2
&& (base.b as i16 - 0x48).abs() <= 2,
"phase 0은 기준색에 근사: {base:?}"
);
let q = pulse_color(95.0, 7_500, true, &cfg).expect("hue 틴트");
let h = pulse_color(95.0, 15_000, true, &cfg).expect("hue 틴트");
assert_ne!(base, q);
assert_ne!(q, h);
}
#[test]
fn pulse_color_off_is_none_regardless_of_style() {
for style in ["calm", "flash", "hue", "swap"] {
let mut cfg = Config::default();
cfg.pulse.pulse_style = style.to_string();
assert_eq!(pulse_color(95.0, 1_234, false, &cfg), None, "{style} OFF");
}
}
#[test]
fn pick_emoji_swap_alternates_glyph() {
let mut cfg = Config::default(); cfg.pulse.pulse_style = "swap".to_string();
assert_eq!(pick_emoji(95.0, 0, true, &cfg), "◆");
assert_eq!(pick_emoji(95.0, 20_000, true, &cfg), "◇");
}
#[test]
fn pick_emoji_swap_stable_when_off() {
let mut cfg = Config::default();
cfg.pulse.pulse_style = "swap".to_string();
assert_eq!(pick_emoji(95.0, 0, false, &cfg), "◆");
assert_eq!(pick_emoji(95.0, 20_000, false, &cfg), "◆");
}
#[test]
fn pick_emoji_non_swap_styles_stable() {
for style in ["calm", "flash", "hue"] {
let mut cfg = Config::default();
cfg.pulse.pulse_style = style.to_string();
assert_eq!(pick_emoji(95.0, 0, true, &cfg), "◆", "{style} 전반");
assert_eq!(pick_emoji(95.0, 20_000, true, &cfg), "◆", "{style} 후반");
}
}
#[test]
fn pulse_styles_registry() {
assert!(is_known_pulse_style("calm"));
assert!(is_known_pulse_style("flash"));
assert!(is_known_pulse_style("hue"));
assert!(is_known_pulse_style("swap"));
assert!(!is_known_pulse_style("bogus"));
assert_eq!(PULSE_STYLES, &["calm", "flash", "hue", "swap"]);
}
}