Ogeon / palette

A Rust library for linear color calculations and conversion
Apache License 2.0
748 stars 60 forks source link

Terminal Colors #339

Closed Testare closed 1 month ago

Testare commented 1 year ago

Description

Different Terminal Emulators have different levels of color support

  1. The oldest, of course, don't support changing colors
  2. Then many started supporting 16 standard colors
  3. Some support an ANSI standard set of 256 colors
  4. And now many newer ones support "True Color" (Standard RGB)

This feature request would be for new Color space structs "Term16" and "Term256."

Something like the following (Making some assumption about implementation of Term16 as an enum and Term256 as a wrapper around a u8)

Term16:

let display_color: Rgb = Rgb::new(1.0, 0.0, 0.0);
let term16_color: Term16 = display_color.into_color();
assert_eq!(Term16::Red, term16_color);
assert_eq!(9u8, term16_color.byte());

Term256:

let display_color = Rgb::new(1.0, 0.0, 0.0);
let term256_color: Term256 = display_color.into_color();
assert_eq!(196u8, term256_color.byte)

Motivation

I would like to design a program that can take advantage of true color when available, but when it isn't supported fall back to the earlier standards. I'm using crossterm to display colors using these different standards, but currently I have to manually decide and specify the fallback colors. I looked but could not find a library for finding the closest approximate 4-bit or 8-bit terminal color equivalent of a standard color. It seemed like this would be a good fit, since this library already supports lossy conversion between different color spaces and seems to be well thought-out and maintained.

I'd like to do something like this:

type CrosstermColor = crossterm::style::Color;
fn convert_color(color: Srgb, terminal_support: TerminalSupportSomething) -> Option<crossterm::style::Color> {

    if terminal_support.true_color_supported() {
        Some(CrosstermColor::Rgb(color.red, color.blue, color.green))

    } else if terminal_support.color256_supported() {
        let color256: Term256 = color.into_color();
        Some(CrosstermColor::AnsiValue(color256.byte))

    } else if terminal_support.color16_supported() {
        let color16: Term16 = color.into_color();
        Some(match color16 {
            Term16::Black => CrosstermColor::Black,
            Term16::Red => CrosstermColor::Red,
            Term16::Green => cCrosstermColor::Green,
            <etc>
        })

    } else {
        None
    }
}

Caveats

For Term16, some Terminals like Alacritty or MacOS Terminal sometimes the actual colors displayed from the standard 16 to different color themes. This is especially true as some of the standard 16 colors can be hard to read on a black background.

Ogeon commented 1 year ago

Hi, thanks for the suggestion! The first 16 colors are quite theme dependent, so it's going to be hard to get a universally good fit. I suppose an estimate may be good enough but that still becomes quite application dependent.

That said, since these sets of colors are more like palettes than spaces, I would suggest searching for the colors that are the visually closest to your 24 bit colors. Basically like this:

const TERM_16: &[(Term16, Srgb<u8>)] = &[...];

let color: Lab = color.into_linear().into_color();

let mut best_fit = None;
for &(term, term_color) in TERM_16 {
    let diff = color.difference(term_color.into_linear().into_color()):
    best_fit = match best_fit {
        Some((best_term, best_diff)) if best_diff > diff | None => Some((term, diff)),
        best => best,
    };
}

This this is untested, so take it with a grain of salt. It can also be optimized with a cache, for example, but may be fast enough as it is. At least for the 16 base colors.

Testare commented 1 year ago

Well, the name of the crate IS pallete XD

But your suggestion makes sense. And for the 256 color pallet, it looks like for 16-231 it moves in steps of 1/6th for each color, and then 232+ it's grayscale. I could probably find the two closest values for red, green, and blue (or 1 if it matches exactly) and then only check the up-to-8 possible combinations for the closest, and derive the value from there? Is that how colors work?

Then I can probably live without n < 16 (Which are the original 16) or n > 231 (which are just more shades of gray).

Or I supposed I can just check all 256 and cache the result, and avoid premature optimization XP

Ogeon commented 1 year ago

Well, the name of the crate IS pallete XD

This is the second time recently that I get to suffer the consequences of my naming choice. :sweat_smile: But my point this time was that I don't see a straight forward way to simply convert a color, at least for the 256 colors, since they are ordered in a quite arbitrary way. Well, I mean each "block" has its own logic. A ready-made implementation would likely be a version of that search, but the choices I made in the example are for high perceived accuracy and that's not necessarily what someone want to optimize for.

You can definitely try to convert the colors to each "block" separately. For the 15 < n < 232 part, it should be possible to essentially reduce them to 1/6th with some offset, as you mentioned, since that's a part of an RGB cube. And you can check if the input color is (near) grayscale and use the n > 231 part for that. It just depends on how close to the original color you want it to be. RGB isn't perceptually uniform, so numerical differences aren't always the same as perceived differences. Green is much brighter than blue, for example. The visual error may end up being larger than with the distance check in Lab (which is much more uniform).

So, in the end it will have to be a tradeoff between best perceived fit and what kind of algorithm that fits. But the mention of a cache was mostly because the example is pretty simplistic. Caching is only relevant for large amounts of values, so I wouldn't bother unless it's noticeably slow. A simple change, if anything, could be to make TERM_256 a (Term256, Lab) table and then you don't pay for 256 conversions every time.

teohhanhui commented 2 months ago

FWIW https://github.com/mina86/ansi_colours seems good.

This crate doesn't have to / shouldn't try to do everything. I think interop would be great.

Ogeon commented 2 months ago

Thanks for mentioning it! Interop via (u8, u8, u8) and other formats should already work out of the box, for example with .into_components(), so they should be fully compatible that way.

teohhanhui commented 2 months ago

There's also https://docs.rs/anstyle-lossy/latest/anstyle_lossy/fn.rgb_to_xterm.html

Ogeon commented 1 month ago

With all the excellent options out there, I agree that it's better to focus on making it easy to convert to and from their preferred formats. I kept this issue open for a bit, to decide if it could be useful to have a separate "palette_palettes" crate, but I think the question can be brought up again if that ever happens.