stanza

Crates.iostanza
lib.rsstanza
version0.5.1
sourcesrc
created_at2022-10-02 21:32:56.191177
updated_at2023-12-20 20:05:19.003032
descriptionAn abstract table model with customisable text formatting and renderers.
homepage
repositoryhttps://github.com/obsidiandynamics/stanza
max_upload_size
id678593
size130,541
Emil Koutanov (ekoutanov)

documentation

README

Stanza

An abstract table model written in Rust, with customisable text formatting and renderers.

Crates.io docs.rs Build Status codecov no_std

Screenshot

Why Stanza

  • Feature-complete: Stanza supports a broad range of styling features — various text formatting controls, foreground/background/fill colours, border styles, multiple horizontal and vertical headers and separators, and even nested tables, to name a few.
  • Pluggable renderers: The clean separation of the table model from the render implementation lets you switch between output formats. For example, the table that you might output to the terminal can be switched to produce a Markdown document instead. You can also add your own render; e.g., to output HTML or paint a TUI/Curses screen.
  • Ease of use: Simple things are easy to do and hard things are possible. Stanza offers both a fluid API for building "static" tables and an API for building a table programmatically.
  • No standard library needed: Stanza is no_std, meaning it can be used in embedded devices.
  • Performance: It takes ~10 µs to build the table model used in the screenshot above and ~200 µs to render it. (Markdown takes roughly half that time.) Efficiency mightn't sound like a concern in desktop and server use cases, but it makes a difference in low-powered devices.

Getting Started

Add dependency

cargo add stanza

A basic table

Stanza intentionally separates the Table model from the set of optional Style modifiers and, finally, the Renderer that is used to turn the table into a printable Display object.

There are four main types of elements within the model: Table, Col, Row and Cell.

A Table holds both Col and Row objects. Tables in Stanza are row-oriented; i.e., a Col is used purely for assigning styles; Row is where the data lives.

A Row contains a vector of Cells. In turn, a Cell houses a Content enum. The simplest and most common content type that you will use is a Content::Label.

Let's start by creating a most basic, 2x3 table.

use stanza::renderer::console::Console;
use stanza::renderer::Renderer;
use stanza::table::Table;

// build a table model
let table = Table::default()
    .with_row(["Department", "Budget"])
    .with_row(["Sales", "90000"])
    .with_row(["Engineering", "270000"]);

// configure a renderer that will later turn the model into a string
let renderer = Console::default();

// render the table, outputting to stdout
println!("{}", renderer.render(&table));

The printing of the table here is clearly split into two distinct steps:

  1. Building the table model.
  2. Invoking a renderer on the model to produce the output.

The resulting output:

╔═══════════╤══════╗
║Department │Budget║
╟───────────┼──────╢
║Sales      │90000 ║
╟───────────┼──────╢
║Engineering│270000║
╚═══════════╧══════╝

Not bad for a half-dozen lines. We used all the concepts above without specifying them explicitly. For example, we didn't refer to Col, Cell or Content types at all. Stanza offers a highly abridged syntax for building tables where additional flexibility mightn't be needed. Still, it's worth understanding what happens under the hood. The same table model can be produced using the fully explicit syntax below.

use stanza::style::Styles;
use stanza::table::{Cell, Col, Content, Row, Table};

Table::with_styles(Styles::default())
    .with_cols(vec![
        Col::new(Styles::default()),
        Col::new(Styles::default()),
    ])
    .with_row(Row::new(
        Styles::default(),
        vec![
            Cell::new(
                Styles::default(),
                Content::Label(String::from("Department")),
            ),
            Cell::new(Styles::default(), Content::Label(String::from("Budget"))),
        ],
    ))
    .with_row(Row::new(
        Styles::default(),
        vec![
            Cell::new(Styles::default(), Content::Label(String::from("Sales"))),
            Cell::new(Styles::default(), Content::Label(String::from("90000"))),
        ],
    ))
    .with_row(Row::new(
        Styles::default(),
        vec![
            Cell::new(
                Styles::default(),
                Content::Label(String::from("Engineering")),
            ),
            Cell::new(Styles::default(), Content::Label(String::from("270000"))),
        ],
    ));

We've gone from 4 lines to over 30, but that's what it takes to specify this model using the fully explicit syntax. You'll be pleased to know that Stanza's syntax is not a binary either-or: it lets you progressively use more explicit constructs as you need to refine your styles or layouts. Note also that we didn't need to specify columns in the first cut — the table model will autogenerate the column definitions based on the number of cells.

Our table lacks a few niceties, however. Ideally, we would like to top row to act as a header. The context is also a little cramped. And finally, the budget figures should ideally be right-aligned. Lets a build a more stylish table.

use stanza::renderer::console::Console;
use stanza::renderer::Renderer;
use stanza::style::{HAlign, Header, MinWidth, Styles};
use stanza::table::{Cell, Col, Row, Table};

let table = Table::default()
    .with_cols(vec![
        Col::new(Styles::default().with(MinWidth(20))),
        Col::new(Styles::default().with(MinWidth(15)).with(HAlign::Right))
    ])
    .with_row(Row::new(
        Styles::default().with(Header(true)),
        vec!["Department".into(), "Budget".into()],
    ))
    .with_row(["Sales", "90000"])
    .with_row(["Engineering", "270000"]);

let renderer = Console::default();
println!("{}", renderer.render(&table));
╔════════════════════╤═══════════════╗
║Department          │         Budget║
╠════════════════════╪═══════════════╣
║Sales               │          90000║
╟────────────────────┼───────────────╢
║Engineering         │         270000║
╚════════════════════╧═══════════════╝

Earlier, it was promised that you could output the same table model into a variety of formats. To switch to Markdown, simply replace the renderer:

use stanza::renderer::markdown::Markdown;
use stanza::renderer::Renderer;

// build the model ...

let renderer = Markdown::default();

// render ...
|Department          |         Budget|
|:-------------------|--------------:|
|Sales               |          90000|
|Engineering         |         270000|

Voilà, we have Markdown!

Styling

This is a good segue into styles. Stanza is built on the philosophy of separating models from renderers. While this offers phenomenal flexibility, it does create a problem. Every renderer is different, which limits the portability of styles. For example, the Console renderer supports a lot richer output than, say, Markdown.

When forming the table model, we almost always have a particular output format in mind. Call it the "preferred" format. It's normal to decorate the table for the preferred format. What we'd also like is to render the table in some alternate format without breaking the output or, worse, causing a panic because the alternate render mightn't support some style modifier.

Styles are always optional; it is up to the renderer to make use of a style if it can do so meaningfully. Renderers ignore styles they don't understand and may downgrade a style that isn't fully supported while preserving the legibility of the content. For example, Markdown is oblivious to the Blink style, but it will still render the text — albeit without the blinking effect. You might even create your own renderer someday and a bunch of styles that only that renderer understands. This won't affect the existing renderers in the slightest.

To organise styles, Stanza posits two basic rules: specificity and assignability.

Specificity

Styles cascade, much like their CSS counterparts. A style assigned at some higher-level element will automatically be applied to all elements in the layers below it. The specificity hierarchy is shown below.

Table
  └─> Col
       └─> Row
            └─> Cell

Generally, one element is considered to be higher than another if the former intersects with more elements than the latter. As an example, the table intersects all other elements, hence it is at the top of the specificity ordering. Conversely, a cell only intersects itself, one row, one column and one table, placing it firmly at the bottom.

Rows and columns aren't so cut and dried, because a table could be very tall or very wide. However, tall tables are far more frequent. In fact, that's how one generally scales a table — by adding rows, not columns. A column will intersect more elements in more cases; therefore, it is higher in the specificity hierarchy.

The specificity hierarchy is used to resolve conflicting styles. Say, we assigned a TextFg::Magenta style to the table and TextFg::Cyan to the cell. Which style should the cell render with? Cyan, of course. This is how we'd expect a normal spreadsheet to behave. Although the cell inherits styles from the table, the column and the row, its own style overrides any of its parents'.

A style defined at the table level will apply to the table and everything contained within, and may be overridden by any lower-level style. A style defined at the column level will apply to the column and all cells intersected by the column, and may be overridden by a cell style. Similarly, a style defined at the row level will apply to the row and all of its cells, and it may be overridden by the cells equivalently. But what happens when a cell inherits a conflicting style from both the column and the row, but does not have an overriding style of its own? The row style takes precedence, as it is more specific.

Assignability

Although styles cascade in a top-down manner, it doesn't mean that a style may be assigned to any element. Take the Header style, for example. It may be assigned to a row or to a column. (Stanza supports vertical headers.) Might a Header be assigned to the table as a whole? Yes, in which case all rows and columns would be treated as headers. But a header cell makes no sense. Whether a style may be assigned to a particular element can be determined by invoking the S::assignability() static trait method for some S: Style, returning a variant of the Assignability enum. The assignability hierarchy is shown below.

Cell
 ├───> Col
 │       └─┐
 └─> Row   │
      └────┴─> Table

A style that can be assigned to a cell can also be assigned to a row, a column and a table. Notable examples include text formatting styles — Bold, Italic, Underline, Blink, Strikethrough, HAlign, as well as some colouring styles — TextFg, TextBg, TextInvert, FillBg and FillInvert.

Above the cell, the hierarchy is disjoint at the column and row elements. Any style that can be assigned to a row can also be assigned to a table. Similarly, any style that can be assigned to a column can also be assigned to a table. These include Header and Separator.

A style that can be assigned to a row is not necessarily assignable to a column, and vice versa. For example, MinWidth and MaxWidth may only be assigned to a column — they make no sense at the row level. (And certainly not at the cell level.)

Finally, some styles may only be assigned to a table. These include BorderFg and BorderBg — used to alter the colour of all borders in the table.

The assignability rule is enforced at runtime. Attempting to assign a nonassignable style will fail with a panic. As such, changing the returned Assignability value of a style to a more restrictive variant would constitute a breaking change.

With all this in mind, let's create another table that demonstrates overriding styles.

Text handling

We didn't have to work much to get our text laid out well. Often, all that's needed is a MinWidth column style and possibly a HAlign. Sometimes we might have lots of text, which pushes our column size out:

use stanza::renderer::console::Console;
use stanza::renderer::Renderer;
use stanza::style::{Header, MinWidth, Styles};
use stanza::table::{Col, Row, Table};

let table = Table::default()
    .with_cols(vec![
        Col::new(Styles::default().with(MinWidth(15))),
        Col::new(Styles::default().with(MinWidth(30))),
    ])
    .with_row(Row::new(
        Styles::default().with(Header(true)),
        vec!["Poem".into(), "Extract".into()]
    ))
    .with_row(Row::from([
        "Antigonish",
        "Yesterday, upon the stair, I met a man who wasn't there! He wasn't there again today, Oh how I wish he'd go away!"
    ]))
    .with_row(Row::from([
        "The Raven",
        "Ah, distinctly I remember it was in the bleak December; And each separate dying ember wrought its ghost upon the floor."
    ]));

println!("{}", Console::default().render(&table));
╔═══════════════╤═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╗
║Poem           │Extract                                                                                                                ║
╠═══════════════╪═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╣
║Antigonish     │Yesterday, upon the stair, I met a man who wasn't there! He wasn't there again today, Oh how I wish he'd go away!      ║
╟───────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╢
║The Raven      │Ah, distinctly I remember it was in the bleak December; And each separate dying ember wrought its ghost upon the floor.║
╚═══════════════╧═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝

As we expected, the "Extract" column is too wide. A simple way of dealing with this is to set MaxWidth style on the column. Simply append .with(MaxWidth(40)) to the existing styles:

╔═══════════════╤════════════════════════════════════════╗
║Poem           │Extract                                 ║
╠═══════════════╪════════════════════════════════════════╣
║Antigonish     │Yesterday, upon the stair, I met a man  ║
║               │who wasn't there! He wasn't there again ║
║               │today, Oh how I wish he'd go away!      ║
╟───────────────┼────────────────────────────────────────╢
║The Raven      │Ah, distinctly I remember it was in the ║
║               │bleak December; And each separate dying ║
║               │ember wrought its ghost upon the floor. ║
╚═══════════════╧════════════════════════════════════════╝

That's better! But since we're dealing with a poem, we should probably respect the author's choice of line breaks. Stanza supports newline characters, leading to line breaks exactly where you need them. Best of all, you can combine newline characters with MaxWidth, resulting in something like this:

╔═══════════════╤════════════════════════════════════════╗
║Poem           │Extract                                 ║
╠═══════════════╪════════════════════════════════════════╣
║Antigonish     │Yesterday, upon the stair,              ║
║               │I met a man who wasn't there!           ║
║               │He wasn't there again today,            ║
║               │Oh how I wish he'd go away!             ║
╟───────────────┼────────────────────────────────────────╢
║The Raven      │Ah, distinctly I remember it was in the ║
║               │bleak December;                         ║
║               │And each separate dying ember wrought   ║
║               │its ghost upon the floor.               ║
╚═══════════════╧════════════════════════════════════════╝

Headers

The ability to support multiple row and column headers is a feature unique to Stanza. Let's draw a multiplication table to illustrate. This will also double as an example of building tables programmatically.

use stanza::renderer::console::Console;
use stanza::renderer::Renderer;
use stanza::style::{HAlign, Header, MinWidth, Styles};
use stanza::table::{Col, Row, Table};

const NUMS: i8 = 6; // the table will multiply from 1 to NUMS

// builds just the header row -- there should be two (top and bottom)
fn build_header_row() -> Row {
    let mut cells = vec!["".into()];
    for col in 1..=NUMS {
        cells.push(col.into());
    }
    cells.push("".into());
    Row::new(Styles::default().with(Header(true)), cells)
}

// builds all body rows (each row also contains a pair column header cells)
fn build_body_rows() -> Vec<Row> {
    (1..=NUMS)
        .map(|row| {
            let mut cells = vec![row.into()];
            for col in 1..=NUMS {
                cells.push((row * col).into());
            }
            cells.push(row.into());
            Row::new(Styles::default(), cells)
        })
        .collect()
}

let mut table = Table::with_styles(
    Styles::default()
        .with(HAlign::Right) // numbers should be right-aligned
        .with(MinWidth(5)), // give each column some space
)
.with_cols(
    (0..NUMS + 2)
        .map(|col| {
            Col::new(
                // only the first and last columns are headers
                Styles::default().with(Header(col == 0 || col == NUMS + 1)),
            )
        })
        .collect(),
);

table.push_row(build_header_row());
table.push_rows(build_body_rows());
table.push_row(build_header_row());

println!("{}", Console::default().render(&table));
╔═════╦═════╤═════╤═════╤═════╤═════╤═════╦═════╗
║     ║    1│    2│    3│    4│    5│    6║     ║
╠═════╬═════╪═════╪═════╪═════╪═════╪═════╬═════╣
║    1║    1│    2│    3│    4│    5│    6║    1║
╟─────╫─────┼─────┼─────┼─────┼─────┼─────╫─────╢
║    2║    2│    4│    6│    8│   10│   12║    2║
╟─────╫─────┼─────┼─────┼─────┼─────┼─────╫─────╢
║    3║    3│    6│    9│   12│   15│   18║    3║
╟─────╫─────┼─────┼─────┼─────┼─────┼─────╫─────╢
║    4║    4│    8│   12│   16│   20│   24║    4║
╟─────╫─────┼─────┼─────┼─────┼─────┼─────╫─────╢
║    5║    5│   10│   15│   20│   25│   30║    5║
╟─────╫─────┼─────┼─────┼─────┼─────┼─────╫─────╢
║    6║    6│   12│   18│   24│   30│   36║    6║
╠═════╬═════╪═════╪═════╪═════╪═════╪═════╬═════╣
║     ║    1│    2│    3│    4│    5│    6║     ║
╚═════╩═════╧═════╧═════╧═════╧═════╧═════╩═════╝

The handling of row and column headers is entirely renderer-specific. Markdown, for example, supports at most one header row, being the first row in the table. It nonetheless renders the content correctly, albeit not as nicely as Console.

|     |    1|    2|    3|    4|    5|    6|     |
|----:|----:|----:|----:|----:|----:|----:|----:|
|    1|    1|    2|    3|    4|    5|    6|    1|
|    2|    2|    4|    6|    8|   10|   12|    2|
|    3|    3|    6|    9|   12|   15|   18|    3|
|    4|    4|    8|   12|   16|   20|   24|    4|
|    5|    5|   10|   15|   20|   25|   30|    5|
|    6|    6|   12|   18|   24|   30|   36|    6|
|     |    1|    2|    3|    4|    5|    6|     |

Separators

Separators are a means of subdividing a table into logical sections. There could be multiple such sections, and the division might be horizontal, vertical, or both. A separator is created by either assigning a Separator style to a Row or Col, or instantiating the Row/Col using the separator() convenience method. The latter is generally preferred due to brevity; the former allows you to specify additional styles for added flexibility. Let's try this on a basic Sudoku puzzle:

use stanza::renderer::console::Console;
use stanza::renderer::Renderer;
use stanza::style::{HAlign, MinWidth, Styles};
use stanza::table::{Col, Row, Table};

let table = Table::with_styles(Styles::default().with(MinWidth(3)).with(HAlign::Centred))
    .with_cols(vec![
        Col::default(),
        Col::default(),
        Col::default(),
        Col::separator(),
        Col::default(),
        Col::default(),
        Col::default(),
        Col::separator(),
        Col::default(),
        Col::default(),
        Col::default(),
    ])
    .with_row(["5", "3", " ", "", " ", "7", " ", "", " ", " ", " "])
    .with_row(["6", " ", " ", "", "1", "9", "5", "", " ", " ", " "])
    .with_row([" ", "9", "8", "", " ", " ", " ", "", " ", "6", " "])
    .with_row(Row::separator())
    .with_row(["8", " ", " ", "", " ", "6", " ", "", " ", " ", "3"])
    .with_row(["4", " ", " ", "", "8", " ", "3", "", " ", " ", "1"])
    .with_row(["7", " ", " ", "", " ", "2", " ", "", " ", " ", "6"])
    .with_row(Row::separator())
    .with_row([" ", "6", " ", "", " ", " ", " ", "", " ", "2", "8"])
    .with_row([" ", " ", " ", "", "4", "1", "9", "", " ", " ", "5"])
    .with_row([" ", " ", " ", "", " ", "8", " ", "", " ", "7", "9"]);

println!("{}", Console::default().render(&table));
╔═══╤═══╤═══╤═══╤═══╤═══╤═══╤═══╤═══╤═══╤═══╗
║ 5 │ 3 │   │   │   │ 7 │   │   │   │   │   ║
╟───┼───┼───┤   ├───┼───┼───┤   ├───┼───┼───╢
║ 6 │   │   │   │ 1 │ 9 │ 5 │   │   │   │   ║
╟───┼───┼───┤   ├───┼───┼───┤   ├───┼───┼───╢
║   │ 9 │ 8 │   │   │   │   │   │   │ 6 │   ║
╟───┴───┴───┘   └───┴───┴───┘   └───┴───┴───╢
║                                           ║
╟───┬───┬───┐   ┌───┬───┬───┐   ┌───┬───┬───╢
║ 8 │   │   │   │   │ 6 │   │   │   │   │ 3 ║
╟───┼───┼───┤   ├───┼───┼───┤   ├───┼───┼───╢
║ 4 │   │   │   │ 8 │   │ 3 │   │   │   │ 1 ║
╟───┼───┼───┤   ├───┼───┼───┤   ├───┼───┼───╢
║ 7 │   │   │   │   │ 2 │   │   │   │   │ 6 ║
╟───┴───┴───┘   └───┴───┴───┘   └───┴───┴───╢
║                                           ║
╟───┬───┬───┐   ┌───┬───┬───┐   ┌───┬───┬───╢
║   │ 6 │   │   │   │   │   │   │   │ 2 │ 8 ║
╟───┼───┼───┤   ├───┼───┼───┤   ├───┼───┼───╢
║   │   │   │   │ 4 │ 1 │ 9 │   │   │   │ 5 ║
╟───┼───┼───┤   ├───┼───┼───┤   ├───┼───┼───╢
║   │   │   │   │   │ 8 │   │   │   │ 7 │ 9 ║
╚═══╧═══╧═══╧═══╧═══╧═══╧═══╧═══╧═══╧═══╧═══╝

Dynamic content

The data we've been tabulating thus far has been determined at the point of Table creation, using Content::Label under the hood. More often than not, the table model is built as the last step in some process, once all the necessary data is available, and is rendered immediately thereafter.

There is another way, wherein the table model is used purely as a placeholder layout, with maybe a handful of static labels (e.g., headers). The rest of the data can be computed at the point of rendering. This "late bound" approach is made possible by Content::Computed.

The following example shows the difference between an early-bound cell value and a late-bound one. The value in the bottom-left cell is taken by calling current_time() at model build-time. The bottom-right cell uses Content::Computed to embed a closure, which is evaluated when render() is called. The example purposely injects a 2-second wait time so that the two values differ.

use std::thread;
use std::time::Duration;
use stanza::renderer::console::Console;
use stanza::renderer::Renderer;
use stanza::style::{Header, Styles};
use stanza::table::{Content, Row, Table};

fn current_time() -> String {
    chrono::Utc::now().format("%H:%M:%S").to_string()
}

// build the table model
let table = Table::default()
    .with_row(Row::new(
        Styles::default().with(Header(true)),
        vec!["Early-bound".into(), "Late-bound".into()],
    ))
    .with_row(Row::new(
        Styles::default(),
        vec![
            current_time().into(),
            Content::Computed(Box::new(current_time)).into(),
        ],
    ));

// wait a little
thread::sleep(Duration::from_secs(2));

// render the table
println!("{}", Console::default().render(&table));
╔═══════════╤══════════╗
║Early-bound│Late-bound║
╠═══════════╪══════════╣
║07:40:41   │07:40:43  ║
╚═══════════╧══════════╝

Nested tables

The greatest layout flexibility comes from nested tables. Nesting essentially lets you combine differently structured content in the same overarching table.

The next example combines two different data sets and adds a vertical separator for neatness.

use stanza::renderer::console::Console;
use stanza::renderer::Renderer;
use stanza::style::{HAlign, MinWidth, Separator, Styles};
use stanza::table::{Col, Row, Table};

let table = Table::with_styles(Styles::default().with(HAlign::Centred))
    .with_cols(vec![
        Col::default(),
        Col::new(Styles::default().with(Separator(true)).with(MinWidth(5))),
        Col::default()
    ])
    .with_row(Row::from(["Sensors", "", "Stocks"]))
    .with_row(Row::new(Styles::default(), vec![
        Table::default()
            .with_row(Row::from(["Water", "19.3"]))
            .with_row(Row::from(["Oil", "65.1"]))
            .into(),
        "".into(),
        Table::default()
            .with_row(Row::from(["AAPL", "138.20"]))
            .with_row(Row::from(["AMZN", "113.20"]))
            .with_row(Row::from(["IBM", "118.81"]))
            .into(),
    ]));


println!("{}", Console::default().render(&table));
╔════════════╤═════╤═════════════╗
║  Sensors   │     │   Stocks    ║
╟────────────┤     ├─────────────╢
║╔═════╤════╗│     │╔════╤══════╗║
║║Water│19.3║│     │║AAPL│138.20║║
║╟─────┼────╢│     │╟────┼──────╢║
║║Oil  │65.1║│     │║AMZN│113.20║║
║╚═════╧════╝│     │╟────┼──────╢║
║            │     │║IBM │118.81║║
║            │     │╚════╧══════╝║
╚════════════╧═════╧═════════════╝

Note, nesting uses the Content::Nested behind the scenes, although we didn't have to use this enum variant explicitly in the example. That's because the abridged syntax uses into() to convert a Table into a Content::Nested(Table) for us. This is much the same as calling into() on any value that implements ToString — the value will be converted to a Content::Label(String) for us.

A notable limitation of nested tables is that all character formatting of the inner table will be replaced with the format of the outer table cell. You may still use any of the layout styles (HAlign, MinWidth, Header, etc.), it's just the character formatting styles (Bold, Italic, TextFg, etc.) that will be ignored.

Composite content

So far we employed various Content enum variants to assign content of different types — plain text, computed values and nested tables — to any given cell. What if we needed to combine content of several distinct types into a single cell? This is accomplished using the Content::Composite variant.

Let's take the "nested tables" example above. Currently, it displays the labels "Sensor temps" and "Stock prices" in a separate row above the nested tables. Let's merge them into a single cell.

use stanza::renderer::console::Console;
use stanza::renderer::Renderer;
use stanza::style::{HAlign, MinWidth, Separator, Styles};
use stanza::table::{Col, Content, Row, Table};

let table = Table::with_styles(Styles::default().with(HAlign::Centred))
    .with_cols(vec![
        Col::default(),
        Col::new(Styles::default().with(Separator(true)).with(MinWidth(5))),
        Col::default(),
    ])
    .with_row(Row::new(
        Styles::default(),
        vec![
            Content::Composite(vec![
                "Sensors\n".into(),
                Table::default()
                    .with_row(Row::from(["Water", "19.3"]))
                    .with_row(Row::from(["Oil", "65.1"]))
                    .into(),
            ])
            .into(),
            "".into(),
            Content::Composite(vec![
                "Stocks\n".into(),
                Table::default()
                    .with_row(Row::from(["AAPL", "138.20"]))
                    .with_row(Row::from(["AMZN", "113.20"]))
                    .with_row(Row::from(["IBM", "118.81"]))
                    .into(),
            ])
            .into(),
        ],
    ));

println!("{}", Console::default().render(&table));
╔════════════╤═════╤═════════════╗
║  Sensors   │     │   Stocks    ║
║╔═════╤════╗│     │╔════╤══════╗║
║║Water│19.3║│     │║AAPL│138.20║║
║╟─────┼────╢│     │╟────┼──────╢║
║║Oil  │65.1║│     │║AMZN│113.20║║
║╚═════╧════╝│     │╟────┼──────╢║
║            │     │║IBM │118.81║║
║            │     │╚════╧══════╝║
╚════════════╧═════╧═════════════╝

Advanced rendering

There is a more elaborate alternative to the render() method — render_with_hints(), which takes an immutable reference to the Table and a slice of RenderHints. Hints offer advanced control over the renderer's behaviour. They are generally not needed for most use cases — we flew through the previous examples while the renderer correctly did its thing.

Hints are used internally within Stanza to feed additional context to the render that it mightn't be aware of. For example, when you use Content::Nested, the table will be rendered recursively. This presents a problem for inner tables that might be using character formatting styles, which output special ANSI escape sequences (when using the Console renderer). These escape sequences interfere with those generated in the course of styling the outer table cell. To solve this problem, the outer rendering routine passes in RenderHint::Nested during recursion, telling the inner rendering routine to suppress these escape sequences.

Why use hints when the renderer takes care of this automatically? Take nested tables. Although the Content::Nested enum variant is convenient, it is highly inflexible. Using it implies that the nested table will be drawn using the same renderer that is used for the outer table. This is why in our previous examples, the inner tables and the outer table shared the overall look and feel.

To gain full control over the table styles, we need to pre-render the inner tables before embedding the output into the outer table. This way we can control all aspects of the inner render. In fact, one might use a different type of renderer altogether. In the following example, we use two different Console renderers: the outer renderer maintains the default configuration, while the inner renderer is stripped of the outer borders.

use stanza::renderer::console::{Console, Decor};
use stanza::renderer::{Renderer, RenderHint};
use stanza::style::{HAlign, MinWidth, Separator, Styles};
use stanza::table::{Col, Row, Table};

let inner_renderer = Console({
    let mut decor = Decor::default();
    decor.draw_outer_border = false;
    decor
});

let sensors = Table::default()
    .with_row(Row::from(["Water", "19.3"]))
    .with_row(Row::from(["Oil", "65.1"]));

let stocks = Table::default()
    .with_row(Row::from(["AAPL", "138.20"]))
    .with_row(Row::from(["AMZN", "113.20"]))
    .with_row(Row::from(["IBM", "118.81"]));

let outer = Table::with_styles(Styles::default().with(HAlign::Centred))
    .with_cols(vec![
        Col::default(),
        Col::new(Styles::default().with(Separator(true)).with(MinWidth(5))),
        Col::default(),
    ])
    .with_row(Row::from(["Sensors", "", "Stocks"]))
    .with_row(Row::new(
        Styles::default(),
        vec![
            inner_renderer.render_with_hints(&sensors, &[RenderHint::Nested]).into(),
            "".into(),
            inner_renderer.render_with_hints(&stocks, &[RenderHint::Nested]).into()
        ],
    ));

println!("{}", Console::default().render(&outer));
╔══════════╤═════╤═══════════╗
║ Sensors  │     │  Stocks   ║
╟──────────┤     ├───────────╢
║Water│19.3│     │AAPL│138.20║
║─────┼────│     │────┼──────║
║Oil  │65.1│     │AMZN│113.20║
║          │     │────┼──────║
║          │     │IBM │118.81║
╚══════════╧═════╧═══════════╝
Commit count: 85

cargo fmt