Introduction to Rendering

Terminal outputs have significant limitations: single font, single size, no graphics. But modern terminals provide many facilities like true colors, light/dark mode support, adaptive sizing, and more. Rich, helpful, and clear outputs are within reach.

The development reality explains why such output remains rare. From a primitive syntax born in the 1970s to the scattered ecosystem support, it's been a major effort to craft great outputs—and logically, it rarely makes sense to invest that time.

In the past few years, we've made rapid progress. Interactive TUIs have a rich and advanced ecosystem. For non-interactive, textual outputs, we've certainly come far with good crates and tools, but it's still sub-par.

Standout's rendering layer is designed to make crafting polished outputs a breeze by leveraging ideas, tools, and workflows from web applications—a domain in which rich interface authoring has evolved into the best model we've got. (But none of the JavaScript ecosystem chaos, rest assured.)

In this guide, we'll explore what makes great outputs and how Standout helps you get there.

See Also:


What Polished Output Entails

If you're building your CLI in Rust, chances are it's not a throwaway grep-formatting script—if that were the case, nothing beats shells. More likely, your program deals with complex data, logic, and computation, and the full power of Rust matters. In the same way, clear, well-presented, and designed outputs improve your users' experience when parsing that information.

Creating good results depends on discipline, consistency, and above all, experimentation—from exploring options to fine-tuning small details. Unlike code, good layout is experimental and takes many iterations: change, view result, change again, judge the new change, and so on.

The classical setup for shell UIs is anything but conducive to this. All presentation is mixed with code, often with complicated logic, if not coupled to it. Additionally, from escape codes to whitespace handling to spreading visual information across many lines of code, it becomes hard to visualize and change things.

The edit-code-compile-run cycle makes small tweaks take minutes. Sometimes a full hour for a minor change. In that scenario, it's no surprise that people don't bother.


Our Example: tdoo

We'll use tdoo, a simple todo list manager CLI, to demonstrate the rendering layer. Here's our data:

#![allow(unused)]
fn main() {
#[derive(Clone, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Status { Pending, Done }

#[derive(Clone, Serialize)]
pub struct Todo {
    pub title: String,
    pub status: Status,
}

#[derive(Serialize)]
pub struct TodoResult {
    pub message: Option<String>,
    pub todos: Vec<Todo>,
}
}

Our goal: transform this raw data into polished, readable output that adapts to the terminal, respects user preferences, and takes minutes to iterate on—not hours.


The Separation Principle

Standout is designed around a strict separation of logic and presentation. This isn't just architectural nicety—it unlocks a fundamentally better workflow.

Without Separation

Here's the typical approach, tangling logic and output:

#![allow(unused)]
fn main() {
fn list_command(show_all: bool) {
    let todos = storage::list().unwrap();
    println!("\x1b[1;36mYour Todos\x1b[0m");
    println!("──────────");
    for (i, todo) in todos.iter().enumerate() {
        if show_all || todo.status == Status::Pending {
            let marker = if todo.status == Status::Done { "[x]" } else { "[ ]" };
            println!("{}. {} {}", i + 1, marker, todo.title);
        }
    }
    println!("\n{} todos total", todos.len());
}
}

Problems:

  • Escape codes are cryptic and error-prone
  • Changes require recompilation
  • Logic and presentation are intertwined
  • Testing is brittle
  • No easy way to support multiple output formats

With Separation

The same output, properly separated:

#![allow(unused)]
fn main() {
// Handler: pure logic, returns data
pub fn list(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<TodoResult> {
    let show_all = matches.get_flag("all");
    let todos = storage::list()?;

    let filtered: Vec<Todo> = if show_all {
        todos
    } else {
        todos.into_iter()
            .filter(|t| matches!(t.status, Status::Pending))
            .collect()
    };

    Ok(Output::Render(TodoResult {
        message: Some(format!("{} todos total", filtered.len())),
        todos: filtered,
    }))
}
}
{# Template: list.jinja #}
[title]Your Todos[/title]
──────────
{% for todo in todos %}
[{{ todo.status }}]{{ todo.status }}[/{{ todo.status }}]  {{ todo.title }}
{% endfor %}

{% if message %}[muted]{{ message }}[/muted]{% endif %}
# Styles: theme.yaml
title:
  fg: cyan
  bold: true
done: green
pending: yellow
muted:
  dim: true

Now:

  • Logic is testable without output concerns
  • Presentation is declarative and readable
  • Styles are centralized and named semantically
  • Changes to appearance don't require recompilation

Quick Iteration and Workflow

The separation principle enables a radically better workflow. Here's what Standout provides:

1. File-Based Flow

Dedicated files for templates and styles:

  • Lower risk of breaking code—especially relevant for non-developer types like technical designers
  • Simpler diffs and easier navigation
  • Trivial to experiment with variations (duplicate files, swap names)

Directory structure:

src/
├── handlers.rs        # Logic
└── templates/
    └── list.jinja     # Content template
styles/
└── default.yaml       # Visual styling

2. Hot Live Reload

During development, you edit the template or styles and re-run. No compilation. No long turnaround.

This changes the entire experience. You can make and verify small adjustments in seconds. You can extensively fine-tune a command output quickly, then polish the full app in a focused session. Time efficiency aside, the quick iterative cycles encourage caring about smaller details, consistency—the things you forgo when iteration is painful.

(When released, files are compiled into the binary, costing no performance or path-handling headaches in distribution.)

See Rendering System for details on how hot reload works.


Best-of-Breed Specialized Formats

Templates: MiniJinja

Standout uses MiniJinja templates—a Rust implementation of Jinja, a de facto standard for rich and powerful templating. The simple syntax and powerful features let you map template text to actual output much easier than println! spreads.

{% if message %}[accent]{{ message }}[/accent]{% endif %}

{% for todo in todos %}
[{{ todo.status }}]{{ todo.status | upper }}[/{{ todo.status }}]  {{ todo.title }}
{% endfor %}

Benefits:

  • Simple, readable syntax
  • Powerful control flow (loops, conditionals, filters)
  • Partials support: templates can include other templates, enabling reuse across commands
  • Custom filters: for complex presentation needs, write small bits of code and keep templates clean

See Rendering System for template filters and context injection.

Styles: CSS Themes

The styling layer uses CSS files with the familiar syntax you already know, but with simpler semantics tailored for terminals:

.title {
    color: cyan;
    font-weight: bold;
}

.done { color: green; }
.blocked { color: red; }
.pending { color: yellow; }

/* Adaptive for light/dark mode */
@media (prefers-color-scheme: light) {
    .panel { color: black; }
}

@media (prefers-color-scheme: dark) {
    .panel { color: white; }
}

Features:

  • Adaptive attributes: a style can render different values for light and dark modes
  • Theming support: swap the entire visual appearance at once, with themes extending other themes
  • True color: RGB values for precise colors (#ff6b35 or [255, 107, 53])
  • Aliases: semantic names resolve to visual styles (commit-message: title)

YAML syntax is also supported as an alternative. See Rendering System for complete style options.


Template Integration with Styling

Styles are applied with BBCode-like syntax: [style]content[/style]. A familiar, simple, and accessible form.

[title]Your Todos[/title]
{% for todo in todos %}
[{{ todo.status }}]{{ todo.title }}[/{{ todo.status }}]
{% endfor %}

Style tags:

  • Nest properly: [outer][inner]text[/inner][/outer]
  • Can span multiple lines
  • Can contain template logic: [title]{% if x %}{{ x }}{% endif %}[/title]

Graceful Degradation

Single template for rich and plain text. Standout degrades gracefully based on terminal capabilities:

myapp list              # Rich colors (if terminal supports)
myapp list > file.txt   # Plain text (not a TTY)
myapp list | less       # Plain text (pipe)

No separate templates for different output modes. The same template serves both.

Debug Mode

Override auto behavior with --output=term-debug for debugging:

[title]Your Todos[/title]
[pending]pending[/pending]  Implement auth
[done]done[/done]  Fix tests

Style tags remain visible, making it easy to verify correct placement. Useful for testing and automation tools.

See Output Modes for all available output formats.


Tabular Layout

Many commands output lists of things—log entries, servers, todos. These benefit from vertically aligned layouts. Aligning fields seems simple at first, but when you factor in ANSI awareness, flexible size ranges, wrapping behavior, truncation, justification, and expanding cells, it becomes really hard. Those one-off bugs that drive you mad—yeah, those.

Tabular gives you a declarative API, both in Rust and in templates, that handles all of this:

{% set t = tabular([
    {"name": "index", "width": 4},
    {"name": "status", "width": 10},
    {"name": "title", "width": "fill"}
], separator="  ") %}

{% for todo in todos %}
{{ t.row([loop.index, todo.status | style_as(todo.status), todo.title]) }}
{% endfor %}

Output adapts to terminal width:

1.    pending     Implement user authentication
2.    done        Review pull request #142
3.    pending     Update dependencies

Features:

  • Fixed, range, fill, and fractional widths
  • Truncation (start, middle, end) with custom ellipsis
  • Word wrapping for long content
  • Per-column styling
  • Automatic field extraction from structs

See Introduction to Tabular for a comprehensive walkthrough.


Output Control

Standout supports various output formats at runtime with the --output option:

myapp list                    # Auto: rich or plain based on terminal
myapp list --output=term      # Force rich terminal output
myapp list --output=text      # Force plain text
myapp list --output=term-debug # Show style tags for debugging
myapp list --output=json      # JSON serialization
myapp list --output=yaml      # YAML serialization
myapp list --output=csv       # CSV serialization

Structured output for free. Because your handler returns a Serialize-able type, JSON/YAML/CSV outputs work automatically. Automation (tests, scripts, other programs) no longer needs to reverse-engineer data from formatted output.

myapp list --output=json | jq '.tasks[] | select(.status == "blocked")'

Same handler, same types—different output format. This enables API-like behavior from CLI apps without writing separate code paths.

See Output Modes for complete documentation.


Putting It All Together

Here's a complete example of a polished todo list command:

Handler (src/handlers.rs):

#![allow(unused)]
fn main() {
use standout::cli::{CommandContext, HandlerResult, Output};
use clap::ArgMatches;
use serde::Serialize;

#[derive(Clone, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Status { Pending, Done }

#[derive(Clone, Serialize)]
pub struct Todo {
    pub title: String,
    pub status: Status,
}

#[derive(Serialize)]
pub struct TodoResult {
    pub message: Option<String>,
    pub todos: Vec<Todo>,
}

pub fn list(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<TodoResult> {
    let show_all = matches.get_flag("all");
    let todos = storage::list()?;

    let filtered: Vec<Todo> = if show_all {
        todos
    } else {
        todos.into_iter()
            .filter(|t| matches!(t.status, Status::Pending))
            .collect()
    };

    let pending_count = filtered.iter()
        .filter(|t| matches!(t.status, Status::Pending))
        .count();

    Ok(Output::Render(TodoResult {
        message: Some(format!("{} pending", pending_count)),
        todos: filtered,
    }))
}
}

Template (src/templates/list.jinja):

[title]My Todos[/title]

{% set t = tabular([
    {"name": "index", "width": 4},
    {"name": "status", "width": 10},
    {"name": "title", "width": "fill"}
], separator="  ") %}

{% for todo in todos %}
{{ t.row([loop.index, todo.status | style_as(todo.status), todo.title]) }}
{% endfor %}

{% if message %}[muted]{{ message }}[/muted]{% endif %}

Styles (src/styles/default.yaml):

title:
  fg: cyan
  bold: true

done: green
pending: yellow

muted:
  dim: true
  light:
    fg: "#666666"
  dark:
    fg: "#999999"

Output (terminal):

My Todos

1.    pending     Implement user authentication
2.    done        Review pull request #142
3.    pending     Update dependencies

2 pending

With colors, "pending" appears yellow, "done" appears green. The title column fills available space.

Output (--output=json):

{
  "message": "2 pending",
  "todos": [
    {"title": "Implement user authentication", "status": "pending"},
    {"title": "Review pull request #142", "status": "done"},
    {"title": "Update dependencies", "status": "pending"}
  ]
}

Same handler. No additional code.


Summary

Standout's rendering layer transforms CLI output from a chore into a pleasure:

  1. Separation of concerns: Logic returns data. Templates define structure. Styles control appearance.

  2. Fast iteration: Hot reload means edit-and-see in seconds, not minutes. This changes what's practical.

  3. Familiar tools: MiniJinja for templates (Jinja syntax), CSS or YAML for styles. No new languages to learn.

  4. Graceful degradation: One template serves rich terminals, plain pipes, and everything in between.

  5. Structured output for free: JSON, YAML, and CSV outputs work automatically from your serializable types.

  6. Tabular layouts: Declarative column definitions handle alignment, wrapping, truncation, and ANSI-awareness.

The rendering system makes it practical to care about details. When iteration is fast and changes are safe, polish becomes achievable—not aspirational.

For complete API details, see Rendering System.