rtb-tui 0.6.5

Reusable TUI widgets: forms, wizards, prompts, tables. Part of the phpboyscout Rust toolkit.
Documentation
---
title: Render helpers
description: Exact output shape, trailing newlines, empty-input behaviour and failure modes of render_table and render_json.
tags: [reference, output]
---

# Render helpers

Two functions that turn a slice of rows into a string. They exist as a
pair so one `--output text|json` flag can pick between them without the
calling command formatting anything itself.

```rust
#[must_use]
pub fn render_table<R: Tabled>(rows: &[R]) -> String;

pub fn render_json<R: Serialize>(rows: &[R]) -> Result<String, RenderError>;
```

Both take a slice, so both accept `&vec` and `&[..]`. Neither prints
anything. They return the string and leave the writing to you.

## Which `Tabled` to derive

`rtb-tui` re-exports its `tabled` dependency as `rtb_tui::tabled`.
Derive through it:

```rust
use rtb_tui::tabled::Tabled;

#[derive(Tabled)]
#[tabled(crate = "rtb_tui::tabled")]
struct Row {
    name: &'static str,
}
```

The `crate` attribute matters. Without it the derive expands to
`::tabled::Tabled`, which only resolves if you also depend on `tabled`
directly, and then it resolves to *your* `tabled`. When that is a
different version from the one `rtb-tui` uses, it is a different trait,
and `render_table` rejects the row type with E0277 ("the trait bound
`R: tabled::tabled::Tabled` is not satisfied", with a note that there
are multiple versions of `tabled` in the graph).

rtb-tui 0.6.4 moved from `tabled` 0.21 to 0.22, which is how rtb-cli
0.9.1 hit exactly that error.

## `render_table` output shape

The style is `tabled`'s `Style::psql()`, applied unconditionally. There
is no parameter, no builder and no configuration key that changes it.
See [Why the table style is fixed](../explanation/03-why-the-output-helpers-are-a-pair.md#why-the-table-style-is-not-configurable).

For two rows of `struct Row { name: &'static str, count: u32 }`:

```text
 name  | count 
-------+-------
 alpha | 1     
 beta  | 2     
```

Columns are padded to the widest cell, so the rule width follows the
data.

Column headers come from the field names of the `Tabled` derive, in
declaration order. Rename them with `#[tabled(rename = "...")]` on the
field; that is a `tabled` attribute, not an `rtb-tui` one.

The returned string always ends with a newline. If `tabled` did not
produce one, `render_table` appends it, so `print!` is the right macro
and `println!` will add a blank line.

`render_table` is infallible: there is no error type and no panic path
for a type that implements `Tabled`.

## What `render_table` does with an empty slice

An empty slice still renders the header and the separator rule, because
the headers come from the type rather than from the data:

```text
 name | count 
------+-------
```

It does not return an empty string, and it does not return a "no rows"
message. If you want one, check `rows.is_empty()` before calling.

## `render_json` output shape

`serde_json::to_string_pretty`, which is a two-space indent, plus a
trailing newline appended by `render_json`. The top level is always a
JSON array, including for one row and for zero rows:

```json
[
  {
    "name": "alpha",
    "count": 1
  }
]
```

An empty slice renders as `[]` followed by a newline. Field names come
from `serde`, so `#[serde(rename = "...")]` and `#[serde(skip)]` apply
in the usual way. Note that `serde` and `tabled` have separate
rename attributes, so a field renamed for one is not renamed for the
other.

## When `render_json` returns an error

`render_json` returns `RenderError::Json(String)` when
`serde_json::to_string_pretty` fails on the rows. The payload is the
underlying `serde_json::Error` already converted to a string.

In practice `serde_json` fails on very little. Measured against this
crate at 0.6.3, and the first three rows re-checked at 0.6.4 with
`serde_json` 1.0.151:

| Input | Result |
|---|---|
| `HashMap<i32, _>` (integer map keys) | `Ok`, keys are stringified: `{"7": ...}` |
| `f64::NAN`, `f64::INFINITY` | `Ok`, both serialise as `null` |
| `HashMap<(u8, u8), _>` (tuple map keys) | `Err(RenderError::Json("key must be a string"))` |
| A hand-written `Serialize` impl that returns an error | `Err(RenderError::Json(_))` with that impl's message |

So the realistic triggers are a map keyed by something that is not
string-like, and a hand-written `Serialize` impl that refuses a value.
Both are programmer mistakes in the row type rather than anything a
user typed, which is why the error carries a string rather than the
original `serde_json::Error`.

## Using both from one row type

A type that feeds both helpers needs both derives, and they are
independent:

```rust
use rtb_tui::tabled::Tabled;
use serde::Serialize;

#[derive(Tabled, Serialize)]
#[tabled(crate = "rtb_tui::tabled")]
struct Row {
    name: &'static str,
    count: u32,
}
```

`Tabled` renders each field through `Display` unless you tell it
otherwise, which rules out nesting: a field whose type is another
struct will not derive `Tabled` without a per-field `tabled` attribute,
even though `Serialize` handles it happily. A nested shape is the one
case where the text and JSON views of the same type genuinely diverge:
the table flattens or drops what the JSON keeps.