antsee is a library which allows you to effortlessly define human friendly configuration for terminal styles and colours. An example shows it best:

filename.rs

struct Config {
  output_text: Style,

  border_color: Color,
  background_rgb: Rgb,
}

impl Default for Config {
  fn default() -> Self {
    Self {
      output_text: Style::default()
        .fg(Ansi::Red)
        .attributes(Attributes::new().bold()),
      border_color: Fixed::from(XtermColors::Pinky).into(),
      background_rgb: Rgb::from_str("#342398").unwrap(),
    }
  }
}

Serializing Config leads to the following TOML:

border_color = "xterm(Pinky)"
background_rgb = "#342398"

[output_text]
  fg = "Red"

[output_text.attributes]
  bold = true
  dimmed = false
  italic = false
  underline = false
  blink = false
  reverse = false
  hidden = false
  strikethrough = false

As you can see, the configuration serializes with the same hex values, color names, etc that you set up the config with. antsee has color libraries for CSS and xterm color names. In addition, it supports hex values, RGB values, ANSI16 color names, or ANSI256 u8 indices. No matter what you use, it will be deserialized to a simple Color type which can easily be converted to your output library of choice.

antsee does not handle ANSI escape codes in any form. This is not going to change. antsee is not an output library, and is designed to be used alongside a library like nu-ansi-term.

Note that all serde related aspects of the library are behind a feature called “serde”. This is to allow the definition of type conversion crates which convert between antsee and an output crate without relying on serde.

I was building a CLI tool in Rust, and I noticed something peculiar. None of the terminal color/style crates (which I will call ANSI crates from here on out) had good support for serde. The popular crates, which I would consider to be:

  • owo-colors
  • colorize
  • nu-ansi-term
  • yansi

didn’t have any serde support with the exception of nu-ansi-term. However, nu-ansi-term only supported serde in the most basic way, being derived Serialize and Deserialize definitions. To me, this seems like a massive gap in the ecosystem. I decided to fill that gap with a crate called antsee.

However, antsee quite quickly grew to become a rather complex crate. I fear that my documentation writing skills are not sufficient to explain how to use the library, or why it is written the way it is. This blog post is my attempt to slowly introduce all the concepts of antsee in a logical order.

Color types make up the majority of the antsee codebase. antsee aims to define a Color type which can be set by human friendly values like hexcodes, CSS color names, or ANSI16 color names.

There are three fundamental color formats supported by terminals. Our goal is not just to implement each, but to implement intuitive human friendly ways to set them.

Black
Bright Black
Red
Bright Red
Green
Bright Green
Yellow
Bright Yellow
Blue
Bright Blue
Magenta
Bright Magenta
Cyan
Bright Cyan
White
Bright White

This set of colours is practically universally supported. This is also the color set you usually change when you apply a theme to your terminal. It is worth nothing that Black is not necessarily the background color and White is not necessarily the foreground color. These colors are easily representable with a Rust enum:

pub enum Ansi {
    #[default]
    /// Default color (foreground code `39`, background code `49`)
    Default,
    ///Color #0 (foreground code `30`, background code `40`)
    ///
    ///This is not necessarily the background color
    Black,
    /// Color #0 (foreground code `90`, background code `100`)
    DarkGray,
    /// Color #1 (foreground code `31`, background code `41`)
    Red,
    /*
      Rest of the 4-bit colors
    */
}

Adding serde support for this color format is pretty simple. To start, we can derive a Debug implementation and implement Display:

#[derive(Debug)]
enum Ansi {
// ANSI enum content
}

impl std::fmt::Display for Ansi {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        //Hacky display implementation using the auto derived debug
        write!(f, "{:?}", self)
    }
}

This allows us to call .to_string() on enum variants and get their name. For example, calling .to_string() on Ansi::BrightCyan will return BrightCyan. Now we can implement FromStr on our enum.

impl FromStr for Ansi {
    //type Err = ...
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let variants = [
            Self::Black,
            //all the other variants
        ];
        for v in variants {
            if s == v.to_string() {
                return Ok(v);
            }
        }

        Err(//Pretend there's an error type)
    }
}

With that, we can implement serde::Serialize and serde::Deserialize to work with the variant names. In other words, if we put BrightRed in our configuration file, that will map to Ansi::BrightRed.

ANSI 8-bit colors, also called ANSI256, or “fixed” colors (as antsee calls them) are a set of fixed color values with u8 indices. Unlike ANSI 4-bit, this color library usually isn’t styled. There isn’t exact consensus on the RGB values of the various colours, but most terminals follow the xterm mapping.

Defining a type for fixed colors is very simple:

struct Fixed(pub u8);

However, this is not user friendly in the slightest. An 8-bit integer is not an intuitive way to set colors at all. For this reason, we’re going to introduce a color library. This will be elaborated on in Libraries. The basic idea is to map a set of names to the various u8 indices. For example, “Seafoam” maps to index 121.

This color library is generated by a macro. The macro defines an enum called XtermColors where each color is a variant. It then generates multiple functions in the impl block of the enum to map variants to RGB values, color names, and indices.

impl Fixed {
  pub fn set_color(&mut self, color: XtermColors) {
    //Calling color.ansi256() to get the 8-bit color index of color
    self.0 = color.ansi256();
  }
}

Now we have a long list of color names for our fixed colors. Wouldn’t it be great if you could use those color names in the configuration file? To achieve that, we need to implement FromStr again. To distinguish values of XtermColors, we’ll say that they must be wrapped in xterm().

We end up implementing FromStr for Fixed by checking if the string follows the format of xterm([color_name]). If so, we index into XtermColors with color_name.

Now we can implement serde::Deserialize with support for XtermColors.

Finally, we get to 24-bit (RGB) color. To represent a 24-bit color, we can simply define:

struct Rgb([u8;3]);

Since XtermColors already has RGB values, we can allow Rgb to be created from xterm() in the same way as Fixed. However, here we use color.rgb() instead of color.ansi256().

It would also be nice if we could create Rgb values from hexcodes. Finally, we’re going to define another color library here for funsies. This one is CssColors, and contains (you guessed it!) a map of CSS color names to RGB values.

Bringing these three together, FromStr for Rgb goes through the following steps:

  1. Does the string start with #?
  2. Does the string follow the css() format?
  3. Does the string follow the xterm() format?

Each step along the way, it can throw an error. If none of those 3 conditions are true, the string is invalid and an error is returned. Once again, the same logic is used in the serde::Deserialize implementation.

As previously mentioned, color libraries rely on macros to generate an enum and functions to map variants to various values. XtermColors and CssColors share a common trait:

trait ColorLibrary
where
    Self: Sized,
{
    ///The function style wrapper which identifies a value as being from this color library
    const WRAPPER: &str;

    ///Wrap a string in the style wrapper
    fn wrap_name(s: &str) -> String;
    ///Extract a string from the style wrapper
    fn unwrap_name(s: &str) -> &str;

    ///Get a color by name
    fn get_name(s: &str) -> Option<Self>;

    ///Get the name of a color
    fn color_name(&self) -> &'static str;

    ///Get the RGB value of a colour
    fn rgb(&self) -> [u8; 3];
}

XtermColors implements two additional functions. One gets the 8-bit color index of a variant, and one gets a variant by the 8-bit color index.

I’m not going to show the macro definitions here. However, as an example, this is the first line parsed by the macro for CssColors. Each line corresponds to a color. AliceBlue is the variant name, "aliceblue" is the color name, and then the RGB value is on the right. The XTermColors macro is very similar, but it has an additional index field.

AliceBlue, "aliceblue", (240, 248, 255);

Right now, our library has one major flaw. We have implemented all these user friendly ways of deserializing colors, but what about serialising colors? We can define a default configuration where colors are set by hex for example, but once we serialise that default configuration it will just output a [u8;3]; This is where we introduce the concept of a color Source. The basic concept is that each color has two potential sources for its serialised value:

  1. Internal source - the data which defines the color. For example, [u8;3] for Rgb.
  2. External source - the value which set the color. For example, xterm(Seafoam) for Fixed.

If we add a field to Rgb and Fixed to track the value which set the color, we can use that field in a custom Serialize implementation.

In theory, we could just add something like an Rc<str>, but that wouldn’t be very flexible. Rather, lets define an enum:

enum Source<S> {
  Active(S),
  Inactive(S)
}

This enum will store the external source and track whether it is active or not. Think of it like an Option where None also contains the value but indicates that it shouldn’t be used. S can be of any type, but for now it will always be Rc<str>

Now, lets define a trait with functions to manipulate the Source:

trait ColorSource {
    type ExternalSource;

    ///Set an external source on the color
    fn set_external_source(&mut self, value: Self::ExternalSource);
    ///Use the external source for serialization
    fn source_external(&mut self);
    ///Use the internal source (data representation) for serialization
    fn source_internal(&mut self);
}

Then, on our color, we store Source<Rc<str>>, and implement ColorSource.

Whenever we set our color with an external value of some kind, be it a hex color or color library value, we set the source to Source::Active(Rc::from(external_source)).

One major disadvantage of this feature is that we lose Copy on our color types. However, this library is for defining configuration formats. Runtime performance is not a huge deal here.

Now as the final touch, we can define:

#[derive(serde::Serialize, serde::Deserialize)]
#[serde(untagged)]
enum Color {
  Ansi(Ansi),
  Fixed(Fixed),
  Rgb(Rgb)
}

There are some parts missing from all of this, like conversion between color types. Also plenty of impl blocks not shown. However, this section has covered all the most unique aspects of antsee.