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 _ = now_ms;
let _ = pulse_on;
let band = band_index(cpu_percent, cfg);
const FALLBACK: [&str; 5] = ["○", "▁", "▄", "▆", "◆"];
match cfg.cpu.load_glyphs.get(band) {
Some(glyph) if !glyph.is_empty() => glyph.clone(),
_ => FALLBACK[band.min(FALLBACK.len() - 1)].to_string(),
}
}
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 wave = (f64::sin(2.0 * std::f64::consts::PI * phase) + 1.0) / 2.0;
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,
});
Some(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),
})
}
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 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);
}
}