//! The six styles atgc paints with, and the one function that paints. //! //! # Why this exists //! //! Three modules each grew the same four lines — a stringly-typed SGR //! parameter, a `format!("\x1b[{code}m{text}\x1b[0m")`, and a boolean deciding //! whether to bother: //! //! - [`crate::term::say`], for the `warning:` and `note:` prefixes //! - [`crate::cmd::logs::render`]'s `Palette`, for six named colours //! - [`crate::cmd::repo::read`], for one bold heading //! //! Nothing was wrong with any of them, and that is the problem with three //! copies: they agreed by coincidence rather than by construction. `93` is //! `Palette::warn` *and* `say`'s `warning:` prefix because someone typed the //! same two digits twice, and a fourth caller reaching for "the warning //! colour" had nowhere to look it up. //! //! # anstyle rather than our own //! //! [`anstyle`] is the vocabulary crate the Rust CLI ecosystem settled on for //! exactly this, and it was already compiled into this binary — clap builds //! its help on it — so adopting it costs a line in `Cargo.toml` and no //! dependency at all. What it buys is that a colour is a value with a type //! instead of a string that happens to parse: `AnsiColor::BrightYellow` can //! be wrong about which colour is wanted, but it cannot be `"39m"` or `"1;93"` //! or an unterminated escape. //! //! The bytes are unchanged. `anstyle` renders `BrightYellow` as `\x1b[93m` //! and its reset as `\x1b[0m`, which is what all three call sites emitted, and //! `the_rendered_bytes_are_the_ones_we_used_to_write` below pins each of the //! six against the literal escape it replaces. //! //! # What is not here //! //! `anstream`, the companion crate that strips escapes from a stream that //! cannot render them. atgc already decides that once, in //! [`crate::term::hyperlink::escapes_wanted`], from three facts it can state — //! and having *two* mechanisms for "should this be coloured" is worse than //! having one, whichever is cleverer. //! //! The canvas in [`crate::cmd::about`] is also not here. Its cell colour is //! not only a terminal style — the HTML rendering of the same canvas matches //! on it to pick a CSS class — so it is a key shared by two renderers that //! happens to be spelled as an SGR parameter. Giving it a proper `Ink` enum is //! the right fix and a different one; see TODO.md. use anstyle::{AnsiColor, Style}; /// A heading. The only style here that is not a colour, and the only one that /// stays legible on a terminal whose palette has been rethemed. pub const BOLD: Style = Style::new().bold(); /// Secondary text: labels, timestamps, anything that should recede. pub const DIM: Style = fg(AnsiColor::BrightBlack); /// A failure — a refused write, an error outcome in a log. pub const BAD: Style = fg(AnsiColor::BrightRed); /// A success, and the one that has to stay distinguishable from [`BAD`] for /// a reader who cannot tell red from green: nothing in atgc uses colour as /// the *only* carrier of an outcome, which is why every painted word here is /// also a word. pub const GOOD: Style = fg(AnsiColor::BrightGreen); /// Something is wrong or incomplete but the result still stands. pub const WARN: Style = fg(AnsiColor::BrightYellow); /// A caveat about a result that is otherwise fine. pub const NOTE: Style = fg(AnsiColor::BrightCyan); /// `const` so the six above can be, which is what lets them be constants /// rather than functions. const fn fg(color: AnsiColor) -> Style { Style::new().fg_color(Some(anstyle::Color::Ansi(color))) } /// `text` in `style`, or `text` alone when `on` is false. /// /// The boolean is passed in rather than read here on purpose. Whether escapes /// are wanted is one decision made in one place, and it is not the same /// question on both streams — stdout answers it through /// [`crate::term::hyperlink::stdout_escapes_wanted`], which `--json` also /// vetoes, while stderr answers it without that veto. A `paint` that read the /// environment itself would have to pick one of those and be wrong for the /// other caller. pub fn paint(on: bool, style: Style, text: &str) -> String { if on { format!("{}{text}{}", style.render(), style.render_reset()) } else { text.to_string() } } #[cfg(test)] mod tests { use super::*; /// The whole claim of this change: the escapes are the same escapes. /// /// Pinned against literals rather than against `anstyle`'s own rendering, /// so this is a test of what reaches a terminal and not a tautology — if /// a future anstyle rendered `BrightYellow` as `\x1b[38;5;11m`, that is /// still a valid encoding of the same colour and this test would still be /// right to fail, because the output would no longer be byte-for-byte /// what every prior release wrote. #[test] fn the_rendered_bytes_are_the_ones_we_used_to_write() { for (style, code) in [ (BOLD, "1"), (DIM, "90"), (BAD, "91"), (GOOD, "92"), (WARN, "93"), (NOTE, "96"), ] { assert_eq!( paint(true, style, "x"), format!("\x1b[{code}mx\x1b[0m"), "{code} no longer renders the way the hand-written escape did" ); } } /// Off is plain text, not an empty escape — a pipe, a redirect or /// `NO_COLOR` gets bytes it can grep. #[test] fn painting_off_emits_no_escapes() { for style in [BOLD, DIM, BAD, GOOD, WARN, NOTE] { let painted = paint(false, style, "x"); assert_eq!(painted, "x"); assert!(!painted.contains('\x1b')); } } /// Six distinct styles. Two that rendered the same would make one of the /// call sites silently lose a distinction it thinks it is drawing. #[test] fn no_two_styles_render_alike() { let mut rendered: Vec = [BOLD, DIM, BAD, GOOD, WARN, NOTE] .into_iter() .map(|s| paint(true, s, "x")) .collect(); rendered.sort_unstable(); rendered.dedup(); assert_eq!(rendered.len(), 6, "two styles render identically"); } }