crates/veilvoice-cli/src/theme.rs
what this file is for · veilvoice-cli · 144 lines · the same file on GitHub
The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.
// SPDX-License-Identifier: GPL-3.0-or-later
//! Tokyo Night colouring for the terminal.
//!
//! The same palette the GUI uses, so the two halves of VeilVoice look like one
//! program. Colour is suppressed when the output is not a terminal, when
//! `NO_COLOR` is set (the widely-honoured convention), or when `TERM=dumb`, so
//! piping to a file or a log never produces escape-code soup.
//!
//! # Why a command-line tool has a palette at all
//!
//! Because the two front-ends are one program. Somebody who uses the desktop
//! application and then runs the binary over SSH should recognise what they are
//! looking at, and the colours carry meaning consistently in both: green for a
//! result, amber for a caveat, red for a refusal, muted for the scope notes
//! that qualify a claim.
//!
//! # Colour is suppressed rather than assumed
//!
//! Three independent conditions turn it off, and all three are checked:
//! output that is not a terminal, `NO_COLOR` set to anything at all (the
//! widely-honoured convention), and `TERM=dumb`. The check runs once through a
//! [`std::sync::OnceLock`] rather than per call, because this is used inside
//! loops that print a line per file.
//!
//! Escape sequences in a log file are worse than no colour: they survive into
//! bug reports, pasted output and issue trackers, where they are noise that
//! obscures the message somebody was trying to show you.
//!
//! # In plain words
//!
//! The colours and the layout of what the terminal prints.
//!
//! The same palette the window uses, so the two halves of VeilVoice look like one
//! program. Colour is dropped automatically when the output is going into a file
//! or another program rather than to a person, because escape codes in a log are
//! noise.
use std::io::IsTerminal;
use std::sync::OnceLock;
/// Tokyo Night, as 24-bit foreground escape sequences.
///
/// The whole palette is defined even though a given build may not use every
/// entry, because the device listing is behind the `live` feature, so its colour goes
/// unused on platforms without an audio backend. Keeping the set complete means
/// it stays a straight mirror of the GUI's palette and of `css/themes.css`,
/// which is what makes the three front-ends look like one program.
#[allow(dead_code)]
pub mod colour {
/// Muted comment grey, for secondary text.
pub const MUTED: &str = "\x1b[38;2;115;122;162m";
/// Foreground blue, for headings and prompts.
pub const BLUE: &str = "\x1b[38;2;122;162;247m";
/// Cyan, for values and figures.
pub const CYAN: &str = "\x1b[38;2;125;207;255m";
/// Green, for success.
pub const GREEN: &str = "\x1b[38;2;158;206;106m";
/// Yellow, for warnings.
pub const YELLOW: &str = "\x1b[38;2;224;175;104m";
/// Red, for errors.
pub const RED: &str = "\x1b[38;2;247;118;142m";
/// Purple, for accents.
pub const PURPLE: &str = "\x1b[38;2;187;154;247m";
/// Reset to the terminal default.
pub const RESET: &str = "\x1b[0m";
}
fn enabled() -> bool {
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| {
if std::env::var_os("NO_COLOR").is_some() {
return false;
}
if std::env::var("TERM").map(|t| t == "dumb").unwrap_or(false) {
return false;
}
std::io::stdout().is_terminal()
})
}
/// Wrap `text` in `colour`, or return it unchanged when colour is off.
pub fn paint(colour: &str, text: &str) -> String {
if enabled() {
format!("{colour}{text}{}", colour::RESET)
} else {
text.to_string()
}
}
/// A success line.
pub fn ok(text: &str) -> String {
format!("{} {}", paint(colour::GREEN, "✓"), text)
}
/// A warning line.
pub fn warn(text: &str) -> String {
format!("{} {}", paint(colour::YELLOW, "!"), text)
}
/// An error line.
pub fn err(text: &str) -> String {
format!("{} {}", paint(colour::RED, "✗"), text)
}
/// A section heading.
pub fn heading(text: &str) -> String {
paint(colour::BLUE, text)
}
/// A `label: value` line with the value highlighted.
pub fn field(label: &str, value: &str) -> String {
format!(
" {:<22} {}",
paint(colour::MUTED, label),
paint(colour::CYAN, value)
)
}
#[cfg(test)]
mod tests {
use super::*;
/// Tests capture stdout, so colour is off and `paint` must be a no-op,
/// which is exactly the property that keeps escape codes out of pipes.
#[test]
fn colour_is_disabled_when_not_a_terminal() {
assert_eq!(paint(colour::RED, "hello"), "hello");
}
#[test]
fn helpers_include_their_text() {
assert!(ok("done").contains("done"));
assert!(warn("careful").contains("careful"));
assert!(err("broken").contains("broken"));
assert!(heading("Section").contains("Section"));
}
#[test]
fn field_shows_both_halves() {
let line = field("Sample rate", "48000 Hz");
assert!(line.contains("Sample rate"));
assert!(line.contains("48000 Hz"));
}
}