Skip to content
46 changes: 24 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ Rust.
- Support for basic colors, bright colors, and background colors
- Text styling (bold, dim, italic, underline, inverse, strikethrough)
- RGB and HEX color support for both text and background
- Style chaining
- Composed style chaining with predictable override behavior
- Works with string literals, owned strings, and format macros
- Zero dependencies
- Supports the `NO_COLOR` environment variable - if this is set, all colors are
disabled and the text is returned uncolored
- Supports explicit runtime color modes: `Auto`, `Always`, and `Never`
- Detects if the output is NOT going to a terminal (e.g. is going to a file or a
pipe) and disables colors if so (this check can also be disabled)
pipe) and disables colors in `Auto` mode
- Complete documentation and examples

## Installation
Expand Down Expand Up @@ -62,7 +63,10 @@ println!("{}", "Italic blue on yellow".blue().italic().on_yellow());

// Using with format! macro
let name = "World";
println!("{}", format!("Hello, {}!", name.blue().bold()));
println!("Hello, {}!", name.blue().bold());

// Removing all styles
println!("{}", "Back to plain text".red().bold().clear());
```

## Available Methods
Expand Down Expand Up @@ -130,8 +134,8 @@ println!("{}", format!("Hello, {}!", name.blue().bold()));
- RGB values must be in range 0-255 (enforced at compile time via `u8` type)
- Attempting to use RGB values > 255 will result in a compile error
- Hex color codes can be provided with or without the '#' prefix
- Invalid hex codes (wrong length, invalid characters) will result in uncolored
text
- Invalid hex codes (wrong length, invalid characters) will result in plain
unstyled text
- All color methods are guaranteed to return a valid string, never panicking

```rust
Expand Down Expand Up @@ -169,32 +173,30 @@ std::env::set_var("NO_COLOR", "1");
println!("{}", "Red text".red()); // Prints without color
```

## Terminal Detection Configuration
## Runtime Color Modes

By default, this library checks if the output is going to a terminal and
disables colors when it's not (e.g., when piping output to a file). This
behavior can be controlled using `ColorizeConfig`:
By default, this library uses `ColorMode::Auto`: it checks if stdout is going to
a terminal and disables colors when it is not. Applications can override that
behavior explicitly using `ColorizeConfig`:

```rust
use colored_text::{Colorize, ColorizeConfig};
use colored_text::{ColorMode, Colorize, ColorizeConfig};

// Disable terminal detection (colors will be enabled regardless of terminal status)
ColorizeConfig::set_terminal_check(false);
ColorizeConfig::set_color_mode(ColorMode::Always);
println!("{}", "Always colored".red());

// Re-enable terminal detection (default behavior)
ColorizeConfig::set_terminal_check(true);
println!("{}", "Only colored in terminal".red());
ColorizeConfig::set_color_mode(ColorMode::Never);
println!("{}", "Never colored".red());

ColorizeConfig::set_color_mode(ColorMode::Auto);
println!("{}", "Colored only in terminals".red());
```

This is particularly useful in test environments where you might want to
force-enable colors regardless of the terminal status. The configuration is
thread-local, making it safe to use in parallel tests without affecting other
threads.
The runtime configuration is thread-local. This is useful in tests or
applications that want to force color on or off for a specific execution path.

Note: Even when terminal detection is disabled, the `NO_COLOR` environment
variable still takes precedence - if it's set, colors will be disabled
regardless of this setting.
`NO_COLOR` still takes precedence in `Auto` and `Always` mode. If `NO_COLOR` is
set, output is plain text.

## Terminal Compatibility

Expand Down
8 changes: 8 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# TODO

- Add a writer-aware rendering API so `ColorMode::Auto` can use the actual
output target instead of always consulting `stdout()`.
- Revisit whether to add `rust-version` to `Cargo.toml` once we want to commit
to an explicit MSRV policy.
- Support 3-character shorthand hex colors like `#f80` in addition to 6-digit
hex input.
16 changes: 9 additions & 7 deletions examples/basic.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use colored_text::Colorize;
use colored_text::{ColorMode, Colorize, ColorizeConfig};

fn main() {
// Basic colors
Expand Down Expand Up @@ -62,7 +62,7 @@ fn main() {
// Using with format! macro
println!("\nUsing with format! macro:");
let name = "World";
println!("{}", format!("Hello, {}!", name.blue().bold()));
println!("Hello, {}!", name.blue().bold());

// Using with String
println!("\nUsing with String:");
Expand All @@ -79,9 +79,11 @@ fn main() {
"important".yellow().underline()
);

// Disabling colors
println!("\nDisabling colors by setting NO_COLOR environment variable:");
std::env::set_var("NO_COLOR", "1");
println!("{}", "This text should have no color".red().bold());
std::env::remove_var("NO_COLOR");
// Runtime color modes
println!("\nRuntime color modes:");
ColorizeConfig::set_color_mode(ColorMode::Always);
println!("{}", "Forced color".red().bold());
ColorizeConfig::set_color_mode(ColorMode::Never);
println!("{}", "Forced plain output".red().bold());
ColorizeConfig::set_color_mode(ColorMode::Auto);
}
125 changes: 125 additions & 0 deletions src/color.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/// Convert HSL color values to RGB.
///
/// - `h`: Hue in degrees
/// - `s`: Saturation percentage
/// - `l`: Lightness percentage
pub(crate) fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
let h = h / 360.0;
let s = s / 100.0;
let l = l / 100.0;

let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let x = c * (1.0 - ((h * 6.0) % 2.0 - 1.0).abs());
let m = l - c / 2.0;

let (r, g, b) = match (h * 6.0) as i32 {
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),
};

(
((r + m) * 255.0) as u8,
((g + m) * 255.0) as u8,
((b + m) * 255.0) as u8,
)
}

pub(crate) fn hex_to_rgb(hex: &str) -> Option<(u8, u8, u8)> {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return None;
}

let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;

Some((r, g, b))
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum NamedColor {
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
White,
BrightRed,
BrightGreen,
BrightYellow,
BrightBlue,
BrightMagenta,
BrightCyan,
BrightWhite,
}

impl NamedColor {
fn foreground_code(self) -> &'static str {
match self {
Self::Black => "30",
Self::Red => "31",
Self::Green => "32",
Self::Yellow => "33",
Self::Blue => "34",
Self::Magenta => "35",
Self::Cyan => "36",
Self::White => "37",
Self::BrightRed => "91",
Self::BrightGreen => "92",
Self::BrightYellow => "93",
Self::BrightBlue => "94",
Self::BrightMagenta => "95",
Self::BrightCyan => "96",
Self::BrightWhite => "97",
}
}

fn background_code(self) -> &'static str {
match self {
Self::Black => "40",
Self::Red => "41",
Self::Green => "42",
Self::Yellow => "43",
Self::Blue => "44",
Self::Magenta => "45",
Self::Cyan => "46",
Self::White => "47",
Self::BrightRed => "101",
Self::BrightGreen => "102",
Self::BrightYellow => "103",
Self::BrightBlue => "104",
Self::BrightMagenta => "105",
Self::BrightCyan => "106",
Self::BrightWhite => "107",
}
}
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum ColorSpec {
Named(NamedColor),
Rgb(u8, u8, u8),
}

impl ColorSpec {
pub(crate) fn foreground_code(&self) -> String {
match self {
Self::Named(color) => color.foreground_code().to_string(),
Self::Rgb(r, g, b) => format!("38;2;{};{};{}", r, g, b),
}
}

pub(crate) fn background_code(&self) -> String {
match self {
Self::Named(color) => color.background_code().to_string(),
Self::Rgb(r, g, b) => format!("48;2;{};{};{}", r, g, b),
}
}
}
101 changes: 101 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
use std::cell::RefCell;
use std::io::IsTerminal;

/// Runtime color policy for rendered output.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum ColorMode {
/// Enable styling only when stdout is a terminal.
#[default]
Auto,
/// Always emit styling, even when stdout is not a terminal.
///
/// `NO_COLOR` still takes precedence and disables styled output.
Always,
/// Never emit styling.
Never,
}

/// Configuration for controlling runtime color behavior.
///
/// The active configuration is stored per thread. This makes it straightforward
/// to force a specific color mode in tests or narrow execution paths without
/// changing global process state.
#[derive(Clone, Debug)]
pub struct ColorizeConfig {
color_mode: ColorMode,
}

thread_local! {
static CONFIG: RefCell<ColorizeConfig> = RefCell::new(ColorizeConfig::default());
#[cfg(test)]
static TERMINAL_OVERRIDE: RefCell<Option<bool>> = RefCell::new(None);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

impl Default for ColorizeConfig {
fn default() -> Self {
Self {
color_mode: ColorMode::Auto,
}
}
}

impl ColorizeConfig {
/// Set the runtime color policy for the current thread.
///
/// In [`ColorMode::Auto`], styling is emitted only when stdout is a
/// terminal. In [`ColorMode::Always`], styling is emitted regardless of
/// terminal detection. In [`ColorMode::Never`], styling is disabled.
pub fn set_color_mode(mode: ColorMode) {
CONFIG.with(|config| config.borrow_mut().color_mode = mode);
}

/// Get the runtime color policy for the current thread.
pub fn color_mode() -> ColorMode {
CONFIG.with(|config| config.borrow().color_mode)
}

/// Compatibility shim for the previous API.
///
/// `true` maps to [`ColorMode::Auto`], and `false` maps to
/// [`ColorMode::Always`].
#[deprecated(note = "use ColorizeConfig::set_color_mode(ColorMode) instead")]
pub fn set_terminal_check(check: bool) {
let mode = if check {
ColorMode::Auto
} else {
ColorMode::Always
};
Self::set_color_mode(mode);
}
}

/// Evaluate the current runtime color policy for this thread.
///
/// This respects [`ColorizeConfig::color_mode()`], and `NO_COLOR` takes
/// precedence over both [`ColorMode::Auto`] and [`ColorMode::Always`].
pub(crate) fn should_colorize() -> bool {
match ColorizeConfig::color_mode() {
ColorMode::Never => false,
ColorMode::Always => std::env::var_os("NO_COLOR").is_none(),
ColorMode::Auto => std::env::var_os("NO_COLOR").is_none() && stdout_is_terminal(),
}
}

fn stdout_is_terminal() -> bool {
#[cfg(test)]
if let Some(value) = TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow()) {
return value;
}

std::io::stdout().is_terminal()
}

#[cfg(test)]
pub(crate) fn set_terminal_override_for_tests(value: Option<bool>) {
TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow_mut() = value);
}

#[cfg(test)]
pub(crate) fn get_terminal_override_for_tests() -> Option<bool> {
TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow())
}
Loading
Loading