The 1970s: Terminal Colors

In contrast to high-resolution colors, which fit into a nicely uniform representation with a color space tag and three coordinates, terminal color formats from the 1970s and 1980s may not even have coordinates, only integer index values. ANSI escape codes support four different kinds of colors:

Treating these color types uniformly requires one more:

Colorant::Default represents either the default foreground or background color, depending on context. Default colors, like ANSI colors, have no intrinsic color values and hence are abstract. Unlike all other colors, which display just the same in the foreground and background, they also are context-sensitive. However, since prettypretty already needs to distinguish between foreground and background colors for all other colors, having only one context-free default color makes for less awkward use, a claim based on experience.

AnsiColor represents the 16 extended ANSI colors. They are eight base colors—black, red, green, yellow, blue, magenta, cyan, and white—and their bright variations—including bright black and bright white. ANSI colors have names but no agreed-upon, intrinsic color values.

EmbeddedRgb is a 6x6x6 RGB cube, i.e., every coordinate ranges from 0 to 5, inclusive. Xterm’s formula for converting to 24-bit RGB colors is widely accepted. The color swatch below shows all 216 colors, with blue cycling every column, green increasing every six columns, and red increasing every row. It’s only the first of many color swatches throughout the documentation.

GrayGradient represents a 24-step gradient from almost black to almost white. As for the embedded RGB cube, Xterm’s formula for converting to 24-bit RGB grays is widely accepted. The color swatch below illustrates the gray gradient.

TrueColor represents 24-bit RGB colors. Even in the early 1990s, when 24-bit graphic cards first became widely available, the term was a misnomer. For example, Kodak’s Photo CD was introduced at the same time and had a considerably wider gamut than the device RGB of graphic cards. Alas, the term lives on. Terminal emulators often advertise support for 16 million colors by setting the COLORTERM environment variable to truecolor.

Finally, Colorant combines the just listed types with high-resolution colors into a single coherent type for all kinds of colors. It does not model that ANSI colors can appear as themselves and as 8-bit indexed colors. Prettypretty used to include the corresponding wrapper type, but the wrapper didn’t enable new functionality and mostly just got in the way. All color types implement Into<Colorant> and methods that require a colorant usually accept an impl thereof, thus drastically reducing the need to manually wrap colors. A custom conversion function for PyO3 effectively offers the same functionality in Python, which also has Colorant.of().

Coding With Terminal Colors

The example code below illustrates how AnsiColor, EmbeddedRgb, and GrayGradient abstract over the underlying 8-bit index space while also providing convenient access to RGB coordinates and gray levels. Embedded RGB and gray gradient colors also nicely convert to true as well as high-resolution colors, but ANSI colors do not.

#![allow(unused)]
fn main() {
extern crate prettypretty;
use prettypretty::{Color, assert_same_color};
use prettypretty::error::OutOfBoundsError;
use prettypretty::style::{AnsiColor, Colorant, EmbeddedRgb, GrayGradient, TrueColor};
let red = AnsiColor::BrightRed;
assert_eq!(u8::from(red), 9);
// What's the color value of ANSI red? We don't know!

let purple = EmbeddedRgb::new(3, 1, 4)?;
let index = 16 + 3 * 36 + 1 * 6 + 4 * 1;
assert_eq!(index, 134);
assert_eq!(u8::from(purple), index);
assert_eq!(TrueColor::from(purple), TrueColor::new(175, 95, 215));
assert_same_color!(Color::from(purple), Color::from_24bit(175, 95, 215));

let gray = GrayGradient::new(18)?;
let index = 232 + 18;
assert_eq!(index, 250);
assert_eq!(gray.level(), 18);
assert_eq!(u8::from(gray), index);
assert_eq!(TrueColor::from(gray), TrueColor::new(188, 188, 188));
assert_same_color!(Color::from(gray), Color::from_24bit(188, 188, 188));

let green = Colorant::from(71);
assert!(matches!(green, Colorant::Embedded(_)));
if let Colorant::Embedded(also_green) = green {
    assert_eq!(also_green[0], 1);
    assert_eq!(also_green[1], 3);
    assert_eq!(also_green[2], 1);
    assert_eq!(TrueColor::from(also_green), TrueColor::new(95, 175, 95));
    assert_same_color!(Color::from(also_green), Color::from_24bit(95, 175, 95));
} else {
    unreachable!("green is an embedded RGB color")
}
Ok::<(), OutOfBoundsError>(())
}
?

The Python version is next. Even though Python does not support traits including conversion traits, most of the code is quite similar to the Rust version. It does help that the Python version adds a few methods that offer functionality equivalent to conversion traits.

from prettypretty.color import Color
from prettypretty.color.style import AnsiColor, Colorant, EmbeddedRgb, GrayGradient
from prettypretty.color.style import TrueColor
red = AnsiColor.BrightRed
assert red.to_8bit() == 9
# What's the color value of ANSI red? We don't know!

purple = EmbeddedRgb(3, 1, 4)
index = 16 + 3 * 36 + 1 * 6 + 4 * 1
assert index == 134
assert purple.to_8bit() == index
true_purple = TrueColor(*purple.to_24bit())
assert true_purple == TrueColor(175, 95, 215)
assert purple.to_color() == Color.from_24bit(175, 95, 215)

gray = GrayGradient(18)
index = 232 + 18
assert index == 250
assert gray.level() == 18
assert gray.to_8bit() == index
true_gray = TrueColor(*gray.to_24bit())
assert true_gray == TrueColor(188, 188, 188)
assert gray.to_color() == Color.from_24bit(188, 188, 188)

green = Colorant.of(71)
assert isinstance(green, Colorant.Embedded)
also_green = green[0] # The only valid index is 0!
assert also_green[0] == 1
assert also_green[1] == 3
assert also_green[2] == 1
true_green = TrueColor(*green.try_to_24bit())
assert true_green == TrueColor(95, 175, 95)
assert also_green.to_color() == Color.from_24bit(95, 175, 95)
?