Introduction to Tabular
Polished terminal output requires two things: good formatting (see Rendering Introduction) and good layouts. For text-only, non-interactive output, layout mostly means aligning things vertically and controlling how multiple pieces of information are presented together.
Tabular provides a declarative column system with powerful primitives for sizing (fixed, range, fill, fractions), positioning (anchor to right), overflow handling (clip, wrap, truncate), cell alignment, and automated per-column styling.
Tabular is not only about tables. Any listing where items have multiple fields that benefit from vertical alignment is a good candidate—log entries with authors, timestamps, and messages; file listings with names, sizes, and dates; task lists with IDs, titles, and statuses. Add headers, separators, and borders to a tabular layout, and you have a table.
Key capabilities:
- Flexible sizing: Fixed widths, min/max ranges, fill remaining space, fractional proportions
- Smart truncation: Truncate at start, middle, or end with custom ellipsis
- Word wrapping: Wrap long content across multiple lines with proper alignment
- Unicode-aware: CJK characters, combining marks, and ANSI codes handled correctly
- Dynamic styling: Style columns or individual values based on content
In this guide, we will walk from a simple listing to a polished table, exploring the available features.
See Also:
- Introduction to Rendering - templates and styles overview
- Styling System - themes and adaptive styles
Our Example: Task List
We'll build the output for a task list. This is a perfect Tabular use case: each task has an index, title, and status. We want them aligned, readable, and visually clear at a glance.
Here's our data:
#![allow(unused)] fn main() { use serde::Serialize; #[derive(Clone, Serialize)] #[serde(rename_all = "lowercase")] pub enum Status { Pending, Done } #[derive(Clone, Serialize)] struct Task { title: String, status: Status, } let tasks = vec![ Task { title: "Implement user authentication".into(), status: Status::Pending }, Task { title: "Fix payment gateway timeout".into(), status: Status::Pending }, Task { title: "Update documentation for API v2".into(), status: Status::Done }, Task { title: "Review pull request #142".into(), status: Status::Pending }, ]; }
Let's progressively build this from raw output to a polished, professional listing.
Step 1: The Problem with Plain Output
Without any formatting, a naive approach might look like this:
{% for task in tasks %}
{{ loop.index }}. {{ task.title }} {{ task.status }}
{% endfor %}
Output:
1. Implement user authentication pending
2. Fix payment gateway timeout pending
3. Update documentation for API v2 done
4. Review pull request #142 pending
This is barely readable. Fields run together, nothing aligns, and scanning the list requires mental parsing of each line. Let's fix that.
Step 2: Basic Column Alignment with col
The simplest improvement is the col filter. It pads (or truncates) each value to a fixed width:
{% for task in tasks %}
{{ loop.index | col(4) }} {{ task.status | col(10) }} {{ task.title | col(40) }}
{% endfor %}
Output:
1. pending Implement user authentication
2. pending Fix payment gateway timeout
3. done Update documentation for API v2
4. pending Review pull request #142
Already much better. Each column aligns vertically, making it easy to scan. But we've hardcoded widths, and if a title is too long, it gets truncated with ....
Key insight: The
colfilter handles Unicode correctly. CJK characters count as 2 columns, combining marks don't add width, and ANSI escape codes are preserved but not counted.
Step 3: Structured Layout with tabular()
For more control, use the tabular() function. This creates a formatter that you configure once and use for all rows:
{% set t = tabular([
{"name": "index", "width": 4},
{"name": "status", "width": 10},
{"name": "title", "width": 40}
], separator=" ") %}
{% for task in tasks %}
{{ t.row([loop.index, task.status, task.title]) }}
{% endfor %}
The output looks the same, but now the column definitions are centralized. This becomes powerful when we start adding features.
Step 4: Flexible Widths
Hardcoded widths are fragile. What if the terminal is wider or narrower? Tabular offers flexible width strategies:
| Width | Meaning |
|---|---|
8 | Exactly 8 columns (fixed) |
{"min": 10} | At least 10, grows to fit content |
{"min": 10, "max": 30} | Between 10 and 30 |
"fill" | Takes all remaining space |
"2fr" | 2 parts of remaining (proportional) |
Let's make the title column expand to fill available space:
{% set t = tabular([
{"name": "index", "width": 4},
{"name": "status", "width": 10},
{"name": "title", "width": "fill"}
], separator=" ") %}
Now on an 80-column terminal:
1. pending Implement user authentication
2. pending Fix payment gateway timeout
3. done Update documentation for API v2
4. pending Review pull request #142
On a 120-column terminal, the title column automatically expands to use the extra space.
The layout adapts to the available space.
Step 5: Right-Align Numbers
Numbers and indices look better right-aligned. Use the align option:
{% set t = tabular([
{"name": "index", "width": 4, "align": "right"},
{"name": "status", "width": 10},
{"name": "title", "width": "fill"}
], separator=" ") %}
Output:
1. pending Implement user authentication
2. pending Fix payment gateway timeout
3. done Update documentation for API v2
4. pending Review pull request #142
The indices now align on the right edge of their column.
Step 6: Anchoring Columns
Sometimes you want a column pinned to the terminal's right edge, regardless of how other columns resize. Use anchor:
{% set t = tabular([
{"name": "index", "width": 4},
{"name": "title", "width": "fill"},
{"name": "status", "width": 10, "anchor": "right"}
], separator=" ") %}
Now the status column is always at the right edge. If the terminal is 100 columns or 200, the status stays anchored. The fill column absorbs the extra space between fixed columns and anchored columns.
Step 7: Handling Long Content
What happens when a title is longer than its column? By default, Tabular truncates at the end with .... But you have options:
Truncate at Different Positions
{"name": "title", "width": 30, "overflow": "truncate"} {# "Very long title th..." #}
{"name": "title", "width": 30, "overflow": {"truncate": {"at": "start"}}} {# "...itle that is long" #}
{"name": "title", "width": 30, "overflow": {"truncate": {"at": "middle"}}} {# "Very long...is long" #}
Middle truncation is perfect for file paths where both the start and end matter: /home/user/.../important.txt
Wrap to Multiple Lines
For descriptions or messages, wrapping is often better than truncating:
{% set t = tabular([
{"name": "index", "width": 4},
{"name": "title", "width": 40, "overflow": "wrap"},
{"name": "status", "width": 10}
], separator=" ") %}
If a title exceeds 40 columns, it wraps:
1. Implement comprehensive error handling pending
for all API endpoints with proper
logging and user feedback
2. Quick fix done
The wrapped lines are indented to align with the column.
Step 8: Dynamic Styling Based on Values
Here's where Tabular shines for task lists. We want status colors: green for done, yellow for pending.
First, define styles in your theme:
/* styles/default.css */
.done { color: green; }
.pending { color: yellow; }
Then use the style_as filter to apply styles based on the value itself:
{% set t = tabular([
{"name": "index", "width": 4},
{"name": "status", "width": 10},
{"name": "title", "width": "fill"}
], separator=" ") %}
{% for task in tasks %}
{{ t.row([loop.index, task.status | style_as(task.status), task.title]) }}
{% endfor %}
The style_as filter wraps the value in style tags: [done]done[/done]. The rendering system then applies the green color.
Output (with colors):
1. [yellow]pending[/yellow] Implement user authentication
2. [yellow]pending[/yellow] Fix payment gateway timeout
3. [green]done[/green] Update documentation for API v2
4. [yellow]pending[/yellow] Review pull request #142
In the terminal, statuses appear in their respective colors, making it instantly clear which tasks need attention.
Step 9: Column-Level Styles
Instead of styling individual values, you can style entire columns. This is useful for de-emphasizing certain information:
{% set t = tabular([
{"name": "index", "width": 4, "style": "muted"},
{"name": "status", "width": 10},
{"name": "title", "width": "fill"}
], separator=" ") %}
Now indices appear in a muted style (typically gray), while titles and statuses remain prominent. This creates visual hierarchy.
Step 10: Automatic Field Extraction
Tired of manually listing [task.title, task.status, ...]? If your column names match your struct fields, use row_from():
{% set t = tabular([
{"name": "title", "width": "fill"},
{"name": "status", "width": 10}
]) %}
{% for task in tasks %}
{{ t.row_from(task) }}
{% endfor %}
Tabular extracts task.title, task.status, etc. automatically. For nested fields, use key:
{"name": "Author", "key": "author.name", "width": 20}
{"name": "Email", "key": "author.email", "width": 30}
Step 11: Adding Headers and Borders
For a proper table with headers, switch from tabular() to table():
{% set t = table([
{"name": "#", "width": 4},
{"name": "Status", "width": 10},
{"name": "Title", "width": "fill"}
], border="rounded", header_style="bold") %}
{{ t.header_row() }}
{{ t.separator_row() }}
{% for task in tasks %}
{{ t.row([loop.index, task.status, task.title]) }}
{% endfor %}
{{ t.bottom_border() }}
Output:
╭──────┬────────────┬────────────────────────────────────────╮
│ # │ Status │ Title │
├──────┼────────────┼────────────────────────────────────────┤
│ 1 │ pending │ Implement user authentication │
│ 2 │ pending │ Fix payment gateway timeout │
│ 3 │ done │ Update documentation for API v2 │
│ 4 │ pending │ Review pull request #142 │
╰──────┴────────────┴────────────────────────────────────────╯
Border Styles
Choose from six border styles:
| Style | Look |
|---|---|
"none" | No borders |
"ascii" | +--+--+ (ASCII compatible) |
"light" | ┌──┬──┐ |
"heavy" | ┏━━┳━━┓ |
"double" | ╔══╦══╗ |
"rounded" | ╭──┬──╮ |
Row Separators
For dense data, add lines between rows:
{% set t = table(columns, border="light", row_separator=true) %}
┌──────┬────────────────────────────────────╮
│ # │ Title │
├──────┼────────────────────────────────────┤
│ 1 │ Implement user authentication │
├──────┼────────────────────────────────────┤
│ 2 │ Fix payment gateway timeout │
└──────┴────────────────────────────────────┘
Step 12: The Complete Example
Putting it all together, here's a polished task list:
{% set t = table([
{"name": "#", "width": 4, "style": "muted"},
{"name": "Status", "width": 10},
{"name": "Title", "width": "fill", "overflow": {"truncate": {"at": "middle"}}}
], border="rounded", header_style="bold", separator=" | ") %}
{{ t.header_row() }}
{{ t.separator_row() }}
{% for task in tasks %}
{{ t.row([loop.index, task.status | style_as(task.status), task.title]) }}
{% endfor %}
{{ t.bottom_border() }}
Output (80 columns, with styling):
╭──────┬────────────┬───────────────────────────────────────────────────────╮
│ # │ Status │ Title │
├──────┼────────────┼───────────────────────────────────────────────────────┤
│ 1 │ pending │ Implement user authentication │
│ 2 │ pending │ Fix payment gateway timeout │
│ 3 │ done │ Update documentation for API v2 │
│ 4 │ pending │ Review pull request #142 │
╰──────┴────────────┴───────────────────────────────────────────────────────╯
Features in use:
- Rounded borders for a modern look
- Muted styling on index column for visual hierarchy
- Fill width on title to use available space
- Middle truncation for titles that exceed the column
- Dynamic status colors via
style_as
Using Tabular from Rust
Everything shown in templates is also available in Rust:
#![allow(unused)] fn main() { use standout_render::tabular::{TabularFormatter, ColumnSpec, Overflow, Alignment}; let columns = vec![ ColumnSpec::fixed(4).header("#").style("muted"), ColumnSpec::fixed(10).header("Status"), ColumnSpec::fill().header("Title").overflow(Overflow::truncate_middle()), ]; let formatter = TabularFormatter::new(columns) .separator(" | ") .terminal_width(80); // Format individual rows for (i, task) in tasks.iter().enumerate() { let row = formatter.format_row(&[ &(i + 1).to_string(), &task.status.to_string(), &task.title, ]); println!("{}", row); } }
Summary
Tabular transforms raw data into polished, scannable output with minimal effort:
- Start simple - use
colfilter for quick alignment - Structure with
tabular()- centralize column definitions - Flex with widths - use
fill, bounded ranges, and fractions - Align content - right-align numbers and dates
- Anchor columns - pin important data to edges
- Handle overflow - truncate intelligently or wrap
- Add visual hierarchy - style columns and values dynamically
- Extract automatically - let
row_from()pull fields from structs - Decorate as tables - add borders, headers, and separators
The declarative approach means your layout adapts to terminal width, handles Unicode correctly, and remains maintainable as your data evolves.
For complete API details, see the API documentation.