zhiburt / tabled

An easy to use library for pretty print tables of Rust structs and enums.
MIT License
2.01k stars 84 forks source link

Question: Is there a way to conditionally apply a cell background color? #365

Closed wbuck closed 1 year ago

wbuck commented 1 year ago

I have a table displaying information about some Azure virtual machines.

I was wondering if there was a built in way to conditionally change the background color of the cells in the status column based on what the status is?

I see that I could individually apply a Cell style via the following:

table
  .with(get_style())
  .with(Modify::new(Cell::new(1, 2)).with(Color::BG_RED | Color::FG_BLACK));

Here I'm applying a red background unconditionally to a single cell. I could use this of course but I'm just wondering if I could set a "rule" for that column which would change the color of each cell depending on what the value of the cell is.

zhiburt commented 1 year ago

Hi @wbuck

So I clearly see 4 ways to do it.

  1. Elaborate tabled::settings::Format (The shortest/simpliest one)
  2. Create a new TableOption/CellOption (Probably more efficient)
  3. Create a new Object to pass to Modify::new (I think good one)
  4. Do it before you create a Table while converting status to String.

Bellow I'll demonstrate first 2. REMEMBER that color feature must be set if colors are used for proper rendering.

In both cases, you'll get the following

image

Base case

use tabled::{
    grid::{
        records::{ExactRecords, Records},
        color::Color as Colorization,
        config::{ColoredConfig, Entity},
        records::vec_records::{CellInfo, VecRecords},
    },
    settings::{object::Rows, style::Style, Alignment, CellOption, Color, Modify},
    Table, Tabled,
};

#[derive(Tabled)]
struct AzureVM {
    subscription_id: String,
    resource_group: String,
    #[tabled(display_with("display_vm_status", self))]
    status: VMStatus,
    #[tabled(display_with = "display_vm_path")]
    path: Option<String>,
}

impl AzureVM {
    fn new(
        subscription_id: &str,
        resource_group: &str,
        status: VMStatus,
        path: Option<String>,
    ) -> Self {
        Self {
            subscription_id: subscription_id.to_string(),
            resource_group: resource_group.to_string(),
            status,
            path,
        }
    }
}

#[derive(Tabled)]
enum VMStatus {
    Up,
    Down,
    Stopped,
    Unknown,
}

fn display_vm_status(vm: &AzureVM) -> String {
    let mut status = display_status(&vm.status);

    let is_wbuck_owner = matches!(&vm.path, Some(path) if path.starts_with("wbuck/"));
    if is_wbuck_owner {
        status.push_str(" (don't worry)");
    }

    status
}

fn display_status(status: &VMStatus) -> String {
    match status {
        VMStatus::Up => "up".to_string(),
        VMStatus::Down => "down".to_string(),
        VMStatus::Stopped => "stopped".to_string(),
        VMStatus::Unknown => "unknown".to_string(),
    }
}

fn display_vm_path(path: &Option<String>) -> String {
    match path {
        Some(path) => format!("vm/{path}"),
        None => String::from("vm/*"),
    }
}

fn collect_vms() -> Vec<AzureVM> {
    vec![
        AzureVM::new("123-123-123", "prod", VMStatus::Up, None),
        AzureVM::new("123-123-123", "prod", VMStatus::Up, None),
        AzureVM::new("123-123-123", "prod", VMStatus::Unknown, None),
        AzureVM::new("123-123-123", "integration", VMStatus::Down, None),
        AzureVM::new("123-000-000", "dev", VMStatus::Stopped, None),
        AzureVM::new("123-111-111", "dev", VMStatus::Up, Some("wbuck/1".to_string())),
    ]
}

fn main() {
    let vms = collect_vms();

    let mut table = Table::new(&vms);
    table
        .with(Style::re_structured_text())
        .with(Modify::new(Rows::first()).with(Alignment::center()));

    println!("{table}");
}
Adding colorization (1)
fn colorize_status(status: &str) -> String {
    let mut buf = String::new();
    let color = vm_status_color(status);
    let _ = color.colorize(&mut buf, status);
    buf
}

fn vm_status_color(status: &str) -> Color {
    match status {
        "up" => Color::BG_BLACK | Color::FG_BRIGHT_GREEN,
        "down" => Color::BG_BLUE | Color::FG_BRIGHT_GREEN,
        "stopped" => Color::BG_BRIGHT_RED | Color::FG_BRIGHT_GREEN | Color::BOLD,
        "unknown" => Color::BG_YELLOW | Color::FG_BRIGHT_GREEN,
        _ => Color::default(),
    }
}

    use tabled::settings::object::{Object, Columns, Rows};
    use tabled::settings::Format;

    let status_column = Columns::single(2).not(Rows::first());
    table.with(Modify::new(status_column).with(Format::content(colorize_status)));
Adding colorization (2)
#[derive(Clone)]
struct StatusColorization;

impl CellOption<VecRecords<CellInfo<String>>, ColoredConfig> for StatusColorization {
    fn change(
        self,
        records: &mut VecRecords<CellInfo<String>>,
        cfg: &mut ColoredConfig,
        entity: Entity,
    ) {
        let (count_rows, count_cols) = (records.count_rows(), records.count_columns());

        for (row, col) in entity.iter(count_rows, count_cols) {
            let status = records[row][col].as_ref();
            let color = vm_status_color(status);
            cfg.set_color(Entity::Cell(row, col), color.into());
        }
    }
}

use tabled::settings::object::{Object, Columns, Rows};

    let status_column = Columns::single(2).not(Rows::first());
    table.with(Modify::new(status_column).with(StatusColorization));

I think it shall help, Let me know if you have any questions.

Take care.


Clearly such a minor task but ... it's too hard than it shall be ....

At first it would be much MUCH MUCH better to operate not on string but on real values (IMHO). I was already thinking a bit how to not lose data types. Essentially to be able to configure table before we convert data to string. But yet haven't came up with a good interface for it....... (It's already possible but quite tediously by configuring a *Config)

If you have ideas please you can share.

As a second improvement your issue give me a though that we could provide a new locator to set a lambda, something like Modify::new(Search::content(|content|)) We already have tabled::settings::locator::ByColumnName.

zhiburt commented 1 year ago

Ahhh yes the example might be too large, but it's mainly a declaration and initialization of AzureVM structures.

I hope the examples are clear.

zhiburt commented 1 year ago

So with a new locator you could do. (But it would be a bit inefficient :()

table
    .with(Modify::new(ByColumnName("status").and(ByContent("UP").or(ByContent("UNKNOWN"))).with(Color::RED))
    .with(Modify::new(ByColumnName("status").and(ByContent("DOWN")).with(Color::BLUE))

Self-Note: ByColumnName could be replaced by ByContent and rows::first combination.

wbuck commented 1 year ago

@zhiburt thank-you for the excellent answer(s) (and library I'm impressed by the API you've designed).

I really like the second option you provided using CellOption and I'm going to give it a go.

What I did for the time being is to add the owo-colors crate and then use it in combination with Format::content:

let format = Format::content(|status| {
       match status {
           "VM deallocated" => status.on_red().black().to_string(),
           "VM running" => status.on_green().black().to_string(),
           // other statuses
       }
    });

    table
        .with(get_style())
        .with(Modify::new(Columns::last().not(Rows::first())).with(format));

But as I noted above I'd like to use the CellOption example you showed instead and not depend on yet another library in this case.

Again, thank-you for your response and detailed examples, I really appreciate it!

satake0916 commented 5 months ago

This issue solved my problem. Thank you!