use std::io::IsTerminal;
use std::sync::OnceLock;
use ratatui::style::{Color as RColor, Modifier, Style as RStyle};
use ratatui_style::{ComputeScratch, NodeRef, Stylesheet};
const THEME_CSS: &str = r#"
:root {
--accent: #ff007f;
--accent2: #00f0ff;
--bg: #080414;
--panel: #140a22;
--fg: #f0e6ff;
--muted: #6a3a7a;
--ok: #39ff14;
--warn: #ffb000;
--alert: #ff2060;
}
/* —— TUI 语义(对齐 ui/theme.rs)—— */
.title,.selected { color: var(--accent); font-weight: bold; }
.border { color: var(--accent2); }
.fg { color: var(--fg); }
.muted { color: var(--muted); }
.accent { color: var(--ok); } /* theme::accent() = 绿 */
.accent2 { color: var(--accent2); }
.bg { background: var(--bg); }
.panel { background: var(--panel); }
.bar { background: var(--panel); color: var(--fg); }
.selected_bar { background: var(--panel); color: var(--accent); font-weight: bold; }
/* —— CLI 语义 —— */
.ok { color: var(--ok); }
.warn { color: var(--warn); }
.err { color: var(--alert); }
.id { color: var(--accent2); }
.key { color: var(--accent2); }
.val { color: var(--fg); }
"#;
pub fn sheet() -> &'static Stylesheet {
static SHEET: OnceLock<Stylesheet> = OnceLock::new();
SHEET.get_or_init(|| {
Stylesheet::parse(THEME_CSS).expect("zkv: theme CSS must parse (authoring bug)")
})
}
thread_local! {
static SCRATCH: std::cell::RefCell<ComputeScratch> =
std::cell::RefCell::new(ComputeScratch::new());
}
pub fn compute(classes: &[&str]) -> RStyle {
SCRATCH.with(|cell| {
let mut g = cell.borrow_mut();
let node = NodeRef::new("Div").classes(classes);
sheet().compute_with(&node, None, &mut g).to_style()
})
}
fn env_override() -> Option<bool> {
if std::env::var_os("NO_COLOR").is_some() {
return Some(false);
}
if let Ok(v) = std::env::var("CLICOLOR_FORCE") {
if v != "0" {
return Some(true);
}
}
None
}
fn stream_enabled(is_tty: bool) -> bool {
if let Some(v) = env_override() {
return v;
}
is_tty && crossterm::style::available_color_count() > 0
}
pub fn stdout_enabled() -> bool {
stream_enabled(std::io::stdout().is_terminal())
}
pub fn stderr_enabled() -> bool {
stream_enabled(std::io::stderr().is_terminal())
}
pub fn paint(enabled: bool, classes: &[&str], text: &str) -> String {
if !enabled || text.is_empty() {
return text.to_string();
}
let open = sgr_open(&compute(classes));
if open.is_empty() {
return text.to_string();
}
format!("{open}{text}\x1b[0m")
}
fn sgr_open(st: &RStyle) -> String {
let mut params: Vec<String> = Vec::new();
let m = st.add_modifier;
if m.contains(Modifier::BOLD) {
params.push("1".into());
}
if m.contains(Modifier::DIM) {
params.push("2".into());
}
if m.contains(Modifier::ITALIC) {
params.push("3".into());
}
if m.contains(Modifier::UNDERLINED) {
params.push("4".into());
}
if m.contains(Modifier::SLOW_BLINK) {
params.push("5".into());
}
if m.contains(Modifier::RAPID_BLINK) {
params.push("6".into());
}
if m.contains(Modifier::REVERSED) {
params.push("7".into());
}
if m.contains(Modifier::HIDDEN) {
params.push("8".into());
}
if m.contains(Modifier::CROSSED_OUT) {
params.push("9".into());
}
if let Some(fg) = st.fg {
push_color(&mut params, fg, false);
}
if let Some(bg) = st.bg {
push_color(&mut params, bg, true);
}
if params.is_empty() {
String::new()
} else {
format!("\x1b[{}m", params.join(";"))
}
}
fn named_index(c: RColor) -> Option<u8> {
Some(match c {
RColor::Black => 0,
RColor::Red => 1,
RColor::Green => 2,
RColor::Yellow => 3,
RColor::Blue => 4,
RColor::Magenta => 5,
RColor::Cyan => 6,
RColor::Gray => 7,
RColor::DarkGray => 8,
RColor::LightRed => 9,
RColor::LightGreen => 10,
RColor::LightYellow => 11,
RColor::LightBlue => 12,
RColor::LightMagenta => 13,
RColor::LightCyan => 14,
RColor::White => 15,
_ => return None,
})
}
fn push_color(params: &mut Vec<String>, c: RColor, is_bg: bool) {
let (extended, base_lo, base_hi): (&str, u32, u32) = if is_bg {
("48", 40, 100)
} else {
("38", 30, 90)
};
match c {
RColor::Reset => {}
RColor::Indexed(i) => params.push(format!("{extended};5;{i}")),
RColor::Rgb(r, g, b) => params.push(format!("{extended};2;{r};{g};{b}")),
other => {
if let Some(idx) = named_index(other) {
let code = if idx < 8 {
base_lo + idx as u32
} else {
base_hi + (idx - 8) as u32
};
params.push(code.to_string());
}
}
}
}
pub fn ok(text: &str) -> String {
paint(stdout_enabled(), &["ok"], text)
}
pub fn muted(text: &str) -> String {
paint(stdout_enabled(), &["muted"], text)
}
pub fn title(text: &str) -> String {
paint(stdout_enabled(), &["title"], text)
}
pub fn id_text(text: &str) -> String {
paint(stdout_enabled(), &["id"], text)
}
pub fn err(text: &str) -> String {
paint(stderr_enabled(), &["err"], text)
}
pub fn warn(text: &str) -> String {
paint(stderr_enabled(), &["warn"], text)
}
pub fn kv(label: &str, val: &str) -> String {
format!("{} {}", paint(stdout_enabled(), &["key"], label), val)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn theme_css_parses() {
Stylesheet::parse(THEME_CSS).expect("theme CSS must parse");
}
#[test]
fn compute_accent_is_pink() {
let st = compute(&["title"]);
assert_eq!(st.fg, Some(RColor::Rgb(0xff, 0x00, 0x7f)));
assert!(st.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn compute_ok_is_green() {
let st = compute(&["ok"]);
assert_eq!(st.fg, Some(RColor::Rgb(0x39, 0xff, 0x14)));
}
#[test]
fn paint_disabled_is_plain() {
assert_eq!(paint(false, &["ok"], "hi"), "hi");
assert_eq!(paint(false, &["ok"], ""), "");
}
#[test]
fn paint_enabled_wraps_truecolor() {
let s = paint(true, &["ok"], "hi");
assert!(s.starts_with("\x1b[38;2;57;255;20m"), "got: {s:?}");
assert!(s.ends_with("\x1b[0m"));
assert!(s.contains("hi"));
}
#[test]
fn paint_merges_modifier_and_color() {
let s = paint(true, &["title"], "x");
assert!(s.starts_with("\x1b[1;38;2;255;0;127m"), "got: {s:?}");
}
}