DEV Community


Bring Some colors to your terminal!

mshel profile image MikhailShel ・4 min read

Its all started when I was driving to an office today,
leaves changing color, sun shining down on different colored cars
on the road, long story short it was super colorful.

When I got my desk I was greeted by dark themed intellij with even darker terminal with lines and lines of super verbose Java. That made me want to escape somewhere... Perhaps Python would of been good, but to challenge myself I've decided something that remotely resembles of both of this languages(Java and Python3) and that is Rust(obviously resembles in terms of Syntax).

So the story below is about me wondering in the land of Rust and the project
that I came up with to bring some color to the dark life in terminal.

The idea is pretty simple: modern terminals support 256 font and background colors, so why won't we generate ascii image previews for images in terminal, this way when you ssh'd to some weird server with some
strange php(or any other language) code on it, you can at least find some joy looking at
ascii representation of images that have been uploaded there :-)

preview of termpic

TLDR: you can get the util here(it's compiled for macOs or 64bit liunux, if you run something else you can get cargo installed and do cargo run to play with):

feel free to create PR for compiled util for windows or in general if you see that something is horribly(or not horribly) wrong out there :-)

Then you can use it either by doing:
cargo run {imageFilePath} or
bin/termpic {imageFilePath}

My first experience with Rust was very pleasant for number of reasons:

Reason 1

It has quite a big collection of packages already, so most of the work was already done for me by crate image and terminal_size.

Reason 2

Rust syntax is simply awesome, just look at following code chunk, I think this one of the clearest way to define open file behaviour(sorta like Pythons with....). However it does take some time to adapt to the whole "taking care of burrowing and references situation". Don't think I'm quite there yet

 let image_result =  image::open(&params[1]);
    match image_result {
        Ok (unwrapped_image) => {
            let mut term_symbols_cache = HashMap::new();
            let aciid_image:String = turn_rgb_frame_to_ascii(unwrapped_image, &mut term_symbols_cache);
            print!("{}", aciid_image);
        Err(message) => {
            println!("{:?}", message)

Reason 3

Its fast

How does it work?

Well it's super simple:

  1. Try to open the image with the code above.
  2. If succeed we go to ascii_frame::turn_rgb_frame_to_ascii::turn_rgb_frame_to_ascii - that returns us ascii string which we simply dump in the terminal

    2.0 Before that^ we also initiate cache for ascii symbols with color there. Notice how we specify that hashmap is mutable so we allow ourself
    to later put items there, also notice how we don't have to specify type for it, that's because later in turn_rgb_frame_to_ascii definition will do that and rust will manage to infer that(awesome right?)

         let mut term_symbols_cache = HashMap::new(); 
         pub fn turn_rgb_frame_to_ascii(frame:DynamicImage, term_symbols_cache: &mut std::collections::HashMap<image::Rgba<u8>, String>) -> String {

    2.1 In turn_rgb_frame_to_ascii first thing we do we call terminal_size to get size of terminal so we won't try to output millions of symbols for super small terminal window(so the smaller terminal screen the worst kind of quality you get while running the util)

        let size = terminal_size();
        let resized_frame;
        if let Some((Width(w), Height(h))) = size {
            resized_frame = frame.resize(w as u32, h as u32, image::FilterType::Nearest);
        } else {
            resized_frame = frame.resize(240, 320, image::FilterType::Nearest);

2.2 Then we go pixel by pixel in our resized image and get ascii value for each of them. Notice the match syntax for our hashMap(😍).

        let mut asciid_frame = String::new(); 

        for row in 0..resized_frame.height() {
            let mut ascii_row = String::new();
            for column in 0..resized_frame.width() {
                let mut _ascii_symb = String::new();
                let pixel = resized_frame.get_pixel(column,row);
                match term_symbols_cache.get(&pixel) {
                    Some(symb) => _ascii_symb = symb.to_string(),
                    None => _ascii_symb = get_ascii_symb_for_pixel(pixel, term_symbols_cache)
                ascii_row += &_ascii_symb;
            asciid_frame += &ascii_row;
            asciid_frame += "\n";

2.3 Everything but one thing should be clear from the code above. Here is this one unclear thing bellow. The magic here is that function get_ascii_for_rgb_arr. Magic of it is explained here. For the purpose of this article though we will avoid discussing details of it :-) And focus of how not hard it is to actually do all this thing in low level programming language

    fn get_ascii_symb_for_pixel(
            term_symbols_cache: &mut std::collections::HashMap<image::Rgba<u8>,String>
            ) -> String {
        let rgb_arr = turn_pixel_to_arr(pixel);
        let mut ascii_symb = String::from("\x1B[48;2;");
        ascii_symb+= &String::from(format!("{};{};{}",pixel[0],pixel[1],pixel[2]));
        ascii_symb+= &String::from("m");
        ascii_symb+= &get_ascii_for_rgb_arr(rgb_arr).to_string();
        ascii_symb+= &String::from("\x1B[0m");
        term_symbols_cache.insert(pixel, ascii_symb.to_string());

    fn turn_pixel_to_arr(rgb_pix:image::Rgba<u8>) -> [f64;3]{
        [rgb_pix[1] as f64, rgb_pix[2] as f64, rgb_pix[3] as f64]

    pub fn  get_ascii_for_rgb_arr(rgb_color:[f64;3]) -> char {
        let char_vec:Vec<char> = "MNHQ$OC?7>!:-;. ".chars().collect();
        let luminosity:f64 = 0.21 * rgb_color[0]+ 0.72 * rgb_color[1] + 0.07 * rgb_color[2];
        return char_vec[( luminosity / 256.0 * char_vec.len() as f64) as usize];

Have an awesome fall everyone!!!


Editor guide