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 tablesColorProfile — what the terminal supports
pub enum ColorProfile {
Mono,
Ansi16,
Ansi256,
TrueColor,
}ColorProfile::detect() reads environment variables and returns the
best available:
| Signal | Result |
|---|---|
NO_COLOR is set (any value) | Mono |
COLORTERM=truecolor or =24bit | TrueColor |
TERM contains 256 | Ansi256 |
| otherwise | Ansi16 |
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-MonoDowngrade — 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 WhiteFor each step that loses precision, the algorithm finds the nearest palette color by Euclidean distance in RGB:
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 ANSIWorked example — a per-profile demo
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 singleu32(alpha in the high byte), zero-overhead and hashable. Lives inftui-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.