Skip to Content
ftui-styleColor profiles

Color and Profiles

Terminals range from monochrome to 24-bit truecolor. FrankenTUI represents every profile as a first-class enum, detects the available profile at startup, and downgrades higher-fidelity values to whatever the terminal can actually render.

The Color enum — four fidelities

pub enum Color { Rgb(Rgb), // 24-bit true color Ansi256(u8), // 8-bit palette index (0..=255) Ansi16(Ansi16), // 16-color ANSI (Black..BrightWhite) Mono(MonoColor), // Black | White only }

Constructors:

Color::rgb(255, 100, 50) // → Color::Rgb Color::Ansi16(Ansi16::Red) Color::Ansi256(208) // orange in the xterm palette Color::Mono(MonoColor::White)

Regardless of variant, you can always ask for the RGB triplet:

let rgb: Rgb = color.to_rgb(); // goes through the palette tables

ColorProfile — what the terminal supports

pub enum ColorProfile { Mono, Ansi16, Ansi256, TrueColor, }

ColorProfile::detect() reads environment variables and returns the best available:

SignalResult
NO_COLOR is set (any value)Mono
COLORTERM=truecolor or =24bitTrueColor
TERM contains 256Ansi256
otherwiseAnsi16

For deterministic tests:

ColorProfile::detect_from_env(no_color, colorterm, term); ColorProfile::from_flags(true_color, colors_256, no_color);

Helpful predicates:

profile.supports_true_color(); profile.supports_256_colors(); profile.supports_color(); // anything non-Mono

Downgrade — the Color::downgrade method

impl Color { pub fn downgrade(self, profile: ColorProfile) -> Color; }

The downgrade path:

TrueColor ──▶ Ansi256 ──▶ Ansi16 ──▶ Mono │ │ │ │ passthrough quantize Euclidean compute luminance RGB → 256 RGB → 16 → Black or White

For each step that loses precision, the algorithm finds the nearest palette color by Euclidean distance in RGB:

d2(c1,c2)=(r1r2)2+(g1g2)2+(b1b2)2d^2(c_1, c_2) = (r_1 - r_2)^2 + (g_1 - g_2)^2 + (b_1 - b_2)^2

That minimization runs against a fixed palette (the standard xterm 256-color table, the 16 ANSI colors, etc.). Results are stable across machines — the palette tables are hardcoded, not OS-dependent.

ColorCache — memoize downgrade results

Downgrades are pure functions of input, so a hash cache fronts the math:

pub struct ColorCache { /* ... */ } impl ColorCache { pub fn new(profile: ColorProfile) -> Self; // cap 4096 pub fn with_capacity(profile: ColorProfile, cap: usize) -> Self; pub fn downgrade_rgb(&mut self, rgb: Rgb) -> Color; pub fn downgrade_packed(&mut self, c: PackedRgba) -> Color; pub fn stats(&self) -> CacheStats; }

Cache hits are a hashmap lookup; misses run the distance search. Eviction is bounded by a simple clear-on-overflow policy (no LRU bookkeeping) since the downgrade values are stable per profile.

Mental model

┌──────────────────┐ source color ────────▶ │ ColorProfile at │ (24-bit RGB in app) │ startup, fixed │ └────────┬─────────┘ ┌──────────────────┐ │ Color::downgrade │ │ (via ColorCache) │ └────────┬─────────┘ quantized color ready for ANSI

Worked example — a per-profile demo

demo_profiles.rs
use ftui_style::{Color, ColorProfile, ColorCache}; fn swatch(profile: ColorProfile) -> Vec<Color> { let mut cache = ColorCache::new(profile); [ (230, 80, 80), (80, 200, 120), (60, 120, 200), (240, 200, 80), ] .map(|(r, g, b)| cache.downgrade_rgb(ftui_style::Rgb::new(r, g, b))) .to_vec() } let truecolor = swatch(ColorProfile::TrueColor); let ansi256 = swatch(ColorProfile::Ansi256); let ansi16 = swatch(ColorProfile::Ansi16);

On a true-color terminal the first array round-trips exactly; on a 16- color terminal, each entry maps to the nearest ANSI palette slot.

Rgb and PackedRgba — two representations

The crate carries two layouts for the same data:

  • Rgb { r, g, b } — three bytes in struct form, human-friendly.
  • PackedRgba — a single u32 (alpha in the high byte), zero-overhead and hashable. Lives in ftui-render::cell::PackedRgba.

Style::fg takes PackedRgba; the WCAG helpers take Rgb. Convert with Rgb::from(packed) / PackedRgba::rgb(r, g, b) as needed.

Pitfalls

NO_COLOR wins. If the user has NO_COLOR=1 in their environment, your carefully-tuned truecolor palette becomes black-and-white at startup. That’s the user’s explicit preference; respect it.

Euclidean distance is not perceptual distance. A downgrade from #ff0000 to the nearest 16-color slot is BrightRed, which looks fine. But some RGB values sit roughly equidistant from two palette entries and the chosen one may surprise you. For precise accessibility guarantees, use WCAG contrast rather than “it’s the same red.”

Don’t re-detect mid-run. ColorProfile::detect() is meant to be called once at startup. Env changes during a session are ignored by design so cached downgrades don’t drift.

Where to go next