Standout
Test your data. Render your view.
Standout is a CLI framework for Rust that enforces separation between logic and presentation. Your handlers return structs, not strings—making CLI logic as testable as any other code.
The Problem
CLI code that mixes logic with println! statements is impossible to unit test:
#![allow(unused)] fn main() { // You can't unit test this—it writes directly to stdout fn list_command(show_all: bool) { let todos = storage::list().unwrap(); println!("Your Todos:"); for todo in todos.iter() { if show_all || todo.status == Status::Pending { println!(" {} {}", if todo.done { "[x]" } else { "[ ]" }, todo.title); } } } }
The only way to test this is regex on captured stdout. That's fragile, verbose, and couples your tests to presentation details.
The Solution
With Standout, handlers return data. The framework handles rendering:
#![allow(unused)] fn main() { // This is unit-testable—it's a pure function that returns data fn list_handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<TodoResult> { let show_all = matches.get_flag("all"); let todos = storage::list()? .into_iter() .filter(|t| show_all || t.status == Status::Pending) .collect(); Ok(Output::Render(TodoResult { todos })) } #[test] fn test_list_filters_completed() { let matches = /* mock ArgMatches with all=false */; let result = list_handler(&matches, &ctx).unwrap(); assert!(result.todos.iter().all(|t| t.status == Status::Pending)); } }
Because your logic returns a struct, you test the struct. No stdout capture, no regex, no brittleness.
Standing Out
What Standout provides:
- Enforced architecture splitting data and presentation
- Logic is testable as any Rust code
- Boilerplateless: declaratively link your handlers to command names and templates, Standout handles the rest
- Autodispatch: save keystrokes with auto dispatch from the known command tree
- Free output handling: rich terminal with graceful degradation, plus structured data (JSON, YAML, CSV)
- Finely crafted output:
- File-based templates for content and CSS for styling
- Rich styling with adaptive properties (light/dark modes), inheritance, and full theming
- Powerful templating through MiniJinja, including partials (reusable, smaller templates for models displayed in multiple places)
- Hot reload: changes to templates and styles don't require compiling
- Declarative layout support for tabular data
Quick Start
1. Define Your Commands and Handlers
Use the Dispatch derive macro to connect commands to handlers. Handlers receive parsed arguments and return serializable data.
#![allow(unused)] fn main() { use standout::cli::{Dispatch, CommandContext, HandlerResult, Output}; use clap::{ArgMatches, Subcommand}; use serde::Serialize; #[derive(Subcommand, Dispatch)] #[dispatch(handlers = handlers)] // handlers are in the `handlers` module pub enum Commands { List, Add { title: String }, } #[derive(Serialize)] struct TodoResult { todos: Vec<Todo>, } mod handlers { use super::*; // HandlerResult<T> wraps your data; Output::Render tells Standout to render it pub fn list(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<TodoResult> { let todos = storage::list()?; Ok(Output::Render(TodoResult { todos })) } pub fn add(m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<TodoResult> { let title: &String = m.get_one("title").unwrap(); let todo = storage::add(title)?; Ok(Output::Render(TodoResult { todos: vec![todo] })) } } }
2. Define Your Presentation
Templates use MiniJinja with semantic style tags. Styles are defined separately in CSS or YAML.
{# list.jinja #}
[title]My Todos[/title]
{% for todo in todos %}
- {{ todo.title }} ([status]{{ todo.status }}[/status])
{% endfor %}
/* styles/default.css */
.title { color: cyan; font-weight: bold; }
.status { color: yellow; }
3. Wire It Up
use standout::cli::App; use standout::{embed_templates, embed_styles}; fn main() -> Result<(), Box<dyn std::error::Error>> { let app = App::builder() .commands(Commands::dispatch_config()) // Register handlers from derive macro .templates(embed_templates!("src/templates")) .styles(embed_styles!("src/styles")) .build()?; app.run(Cli::command(), std::env::args()); Ok(()) }
Run it:
myapp list # Rich terminal output with colors
myapp list --output json # JSON for scripting
myapp list --output yaml # YAML for config files
myapp list --output text # Plain text, no ANSI codes
Features
Architecture
- Logic/presentation separation enforced by design
- Handlers return data; framework handles rendering
- Unit-testable CLI logic without stdout capture
Output Modes
- Rich terminal output with colors and styles
- Automatic JSON, YAML, CSV serialization from the same handler
- Graceful degradation when terminal lacks capabilities
Rendering
- MiniJinja templates with semantic style tags
- CSS or YAML stylesheets with light/dark mode support
- Hot reload during development—edit templates without recompiling
- Tabular layouts with alignment, truncation, and Unicode support
Integration
- Clap integration with automatic dispatch
- Declarative command registration via derive macros
Installation
cargo add standout
Migrating an Existing CLI
Already have a CLI? Standout supports incremental adoption. Standout handles matched commands automatically; unmatched commands return ArgMatches for your existing dispatch:
#![allow(unused)] fn main() { if let Some(matches) = app.run(Cli::command(), std::env::args()) { // Standout didn't handle this command, fall back to legacy your_existing_dispatch(matches); } }
See the Partial Adoption Guide for the full migration path.
Next Steps
- Introduction to Standout — Adopting Standout in a working CLI. Start here.
- Introduction to Rendering — Creating polished terminal output
- Introduction to Tabular — Building aligned, readable tabular layouts
- All Topics — In-depth documentation for specific systems
Guides
Step-by-step walkthroughs covering principles, rationale, and features.
Available Guides
- Introduction to Standout — Adopting Standout in a working CLI. Start here.
- Introduction to Rendering — Creating polished terminal output with templates and styles.
- Introduction to Tabular — Building aligned, readable tabular layouts.
- TLDR Quick Start — Fast-paced intro for experienced developers.
Where to Start
If you're new to Standout, begin with Introduction to Standout. It walks through adopting Standout in an existing CLI, step by step.
For a quick overview without the explanations, see the TLDR Quick Start.
Standout How To
This is a small, focused guide for adopting Standout in a working shell application. Each step is self-sufficient, takes a positive step towards a sane CLI design, and can be incrementally merged. This can be done for one command (probably a good idea), then replicated to as many as you'd like.
Note that only 2 out of 8 steps are Standout related. The others are generally good practices and clear designs for maintainable shell programs. This is not an accident, as Standout's goal is to allow your app to keep a great structure effortlessly, while providing testability, rich and fast output design, and more.
For explanation's sake, we will show a hypothetical list command for tdoo, a todo list manager.
See Also:
- Handler Contract - detailed handler API
- Rendering System - templates and styles in depth
- Output Modes - all output format options
- Partial Adoption - migrating incrementally
1. Start: The Argument Parsing
Arg parsing is insanely intricate and deceptively simple. In case you are not already: define your application's interface with clap. Nothing else is worth doing until you have a sane starting point.
If you don't have clap set up yet, here's a minimal starting point:
use clap::{Parser, Subcommand}; #[derive(Parser)] #[command(name = "tdoo")] struct Cli { #[command(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { /// List all todos List { #[arg(short, long)] all: bool, }, /// Add a new todo Add { title: String, }, } fn main() { let cli = Cli::parse(); match cli.command { Commands::List { all } => list_command(all), Commands::Add { title } => add_command(&title), } }
(If you are using a non-clap-compatible crate, for now, you'd have to write an adapter for clap.)
Verify: Run
cargo build- it should compile without errors.
2. Hard Split Logic and Formatting
Now, your command should be split into two functions: the logic handler and its rendering. Don't worry about the specifics, do the straightest path from your current code.
This is the one key step, the key design rule. And that's not because Standout requires it, rather the other way around: Standout is designed on top of it, and keeping it separate and easy to iterate on both logic and presentation under this design is Standout's key value.
If your CLI is in good shape this will be a small task, otherwise you may find yourself patching together print statements everywhere, tidying up the data model and centralizing the processing. The silver lining here being: if it takes considerable work, there will be considerable gain in doing so.
Before (tangled logic and output):
#![allow(unused)] fn main() { fn list_command(show_all: bool) { let todos = storage::list().unwrap(); println!("Your Todos:"); 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); } } if todos.is_empty() { println!("No todos yet!"); } } }
After (clean separation):
#![allow(unused)] fn main() { use clap::ArgMatches; // Data types for your domain #[derive(Clone)] pub enum Status { Pending, Done } #[derive(Clone)] pub struct Todo { pub title: String, pub status: Status, } pub struct TodoResult { pub message: Option<String>, pub todos: Vec<Todo>, } // This is your core logic handler, receiving parsed clap args // and returning a pure Rust data type. // // Note: This example uses immutable references. If your handler needs // mutable state (&mut self), see the "Mutable Handlers" section below. pub fn list(matches: &ArgMatches) -> TodoResult { let show_done = matches.get_flag("all"); let todos = storage::list().unwrap(); let filtered: Vec<Todo> = if show_done { todos } else { todos.into_iter() .filter(|t| matches!(t.status, Status::Pending)) .collect() }; TodoResult { message: None, todos: filtered, } } // This will take the Rust data type and print the result to stdout pub fn render_list(result: TodoResult) { if let Some(msg) = result.message { println!("{}", msg); } for (i, todo) in result.todos.iter().enumerate() { let status = match todo.status { Status::Done => "[x]", Status::Pending => "[ ]", }; println!("{}. {} {}", i + 1, status, todo.title); } } // And the orchestrator: pub fn list_command(matches: &ArgMatches) { render_list(list(matches)) } }
Verify: Run
cargo buildand thentdoo list- output should look identical to before.
Intermezzo A: Milestone - Logic and Presentation Split
What you achieved: Your command logic is now a pure function that returns data. What's now possible:
- All of your app's logic can be unit tested as any code, from the logic inwards.
- You can test by feeding input strings and verifying your logic handler gets called with the right parameters.
- The rendering can also be tested by feeding data inputs and matching outputs (though this is brittle).
What's next: Making the return type serializable for automatic JSON/YAML output. Your files now:
src/
├── main.rs # clap setup + orchestrators
├── handlers.rs # list(), add() - pure logic
└── render.rs # render_list(), render_add() - output formatting
3. Fine Tune the Logic Handler's Return Type
While any data type works, Standout's renderer takes a generic type that must implement Serialize. This enables automatic JSON/YAML output modes and template rendering through MiniJinja's context system. This is likely a small change, and beneficial as a baseline for logic results that will simplify writing renderers later.
Add serde to your Cargo.toml:
[dependencies]
serde = { version = "1", features = ["derive"] }
Update your types:
#![allow(unused)] fn main() { 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>, } }
Verify: Run
cargo build- it should compile without errors.
4. Replace Imperative Print Statements With a Template
Reading a template of an output next to the substituting variables is much easier to reason about than scattered prints, string concats and the like.
This step is optional - if your current output is simple, you can skip to step 5. If you want an intermediate checkpoint, use Rust's format strings:
#![allow(unused)] fn main() { pub fn render_list(result: TodoResult) { let output = format!( "{header}\n{todos}", header = result.message.unwrap_or_default(), todos = result.todos.iter().enumerate() .map(|(i, t)| format!("{}. [{}] {}", i + 1, t.status, t.title)) .collect::<Vec<_>>() .join("\n") ); println!("{}", output); } }
Verify: Run
tdoo list- output should still work.
5. Use a MiniJinja Template String
Rewrite your std::fmt or imperative prints into a MiniJinja template string, and add minijinja to your crate. If you're not familiar with it, it's a Rust implementation of Jinja, pretty much a de-facto standard for more complex templates.
Resources:
Add minijinja to your Cargo.toml:
[dependencies]
minijinja = "2"
And then you call render in MiniJinja, passing the template string and the data to use. So now your rendering function looks like this:
#![allow(unused)] fn main() { pub fn render_list(result: TodoResult) { let output_tmpl = r#" {% if message %} {{ message }} {% endif %} {% for todo in todos %} {{ loop.index }}. [{{ todo.status }}] {{ todo.title }} {% endfor %} "#; let env = minijinja::Environment::new(); let tmpl = env.template_from_str(output_tmpl).unwrap(); let output = tmpl.render(&result).unwrap(); println!("{}", output); } }
Verify: Run
tdoo list- output should match (formatting may differ slightly).
6. Use a Dedicated Template File
Now, move the template content into a file (say src/templates/list.jinja), and load it in the rendering module. Dedicated files have several advantages: triggering editor/IDE support for the file type, more descriptive diffs, less risk of breaking the code/build and, in the event that you have less technical people helping out with the UI, a much cleaner and simpler way for them to contribute.
Create src/templates/list.jinja:
{% if message %}{{ message }} {% endif %}
{% for todo in todos %}
{{ loop.index }}. [{{ todo.status }}] {{ todo.title }}
{% endfor %}
Update your render function to load from file:
#![allow(unused)] fn main() { pub fn render_list(result: TodoResult) { let template_content = include_str!("templates/list.jinja"); let env = minijinja::Environment::new(); let tmpl = env.template_from_str(template_content).unwrap(); let output = tmpl.render(&result).unwrap(); println!("{}", output); } }
Verify: Run
tdoo list- output should be identical.
Intermezzo B: Declarative Output Definition
What you achieved: Output is now defined declaratively in a template file, separate from Rust code. What's now possible:
- Edit templates without recompiling (with minor changes to loading)
- Non-Rust developers can contribute to UI
- Clear separation in code reviews: "is this a logic change or display change?"
- Use partials, filters, and macros for complex outputs (see Rendering System)
What's next: Hooking up Standout for automatic dispatch and rich output. Also, notice we've yet to do anything Standout-specific. This is not a coincidence—the framework is designed around this pattern, making testability, fast iteration, and rich features natural outcomes of the architecture. Your files now:
src/
├── main.rs
├── handlers.rs
├── render.rs
└── templates/
└── list.jinja
7. Standout: Offload the Handler Orchestration
And now the Standout-specific bits finally show up.
7.1 Add Standout to your Cargo.toml
[dependencies]
standout = { version = "2", features = ["clap", "macros"] }
Verify: Run
cargo build- dependencies should download and compile.
7.2 Create the Commands enum with Dispatch
Annotate your commands enum with the Dispatch derive macro. This tells Standout that the "list" command should be dispatched to the list handler. That's all Standout needs to know, and now it can manage the execution.
See Handler Contract for full handler API details.
#![allow(unused)] fn main() { use standout::cli::{Dispatch, CommandContext, HandlerResult, Output}; use clap::{ArgMatches, Subcommand}; // Define your commands enum with the Dispatch derive #[derive(Subcommand, Dispatch)] #[dispatch(handlers = handlers)] pub enum Commands { List, Add, } // Your handlers module mod handlers { use super::*; pub fn list(_matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<TodoResult> { let todos = storage::list()?; Ok(Output::Render(TodoResult { message: None, todos, })) } pub fn add(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<TodoResult> { let title: &String = matches.get_one("title").unwrap(); let todo = storage::add(title)?; Ok(Output::Render(TodoResult { message: Some(format!("Added: {}", title)), todos: vec![todo], })) } } }
Verify: Run
cargo build- it should compile without errors.
7.3 Configure AppBuilder
Use AppBuilder to configure your app. Instantiate the builder, add the path for your templates. See App Configuration for all configuration options.
#![allow(unused)] fn main() { use standout::cli::App; use standout::{embed_templates, embed_styles}; let app = App::builder() .templates(embed_templates!("src/templates")) // Embeds all .jinja/.j2 files .commands(Commands::dispatch_config()) // Register handlers from derive macro .build()?; }
Verify: Run
cargo build- it should compile without errors.
7.4 Wire up main()
The final bit: handling the dispatching off to Standout:
use standout::cli::App; use standout::embed_templates; fn main() -> anyhow::Result<()> { let app = App::builder() .templates(embed_templates!("src/templates")) .commands(Commands::dispatch_config()) .build()?; // Run with auto dispatch - handles parsing and execution app.run(Cli::command(), std::env::args()); Ok(()) }
If your app has other clap commands that are not managed by Standout, check for unhandled commands. See Partial Adoption for details on incremental migration.
#![allow(unused)] fn main() { if let Some(matches) = app.run(Cli::command(), std::env::args()) { // Standout didn't handle this command, fall back to legacy legacy_dispatch(matches); } }
Verify: Run
tdoo list- it should work as before. Verify: Runtdoo list --output json- you should get JSON output for free!
And now you can remove the boilerplate: the orchestrator (list_command) and the rendering (render_list). You're pretty much at global optima: a single line of derive macro links your app logic to a command name, a few lines configure Standout, and auto dispatch handles all the boilerplate.
For the next commands you'd wish to migrate, this is even simpler. Say you have a "create" logic handler: add a "create.jinja" to that template dir, add the derive macro for the create function and that is it. By default the macro will match the command's name to the handlers and to the template files, but you can change these and map explicitly to your heart's content.
Intermezzo C: Welcome to Standout
What you achieved: Full dispatch pipeline with zero boilerplate.
What's now possible:
- Alter the template and re-run your CLI, without compilation, and the new template will be used
- Your CLI just got multiple output modes via
--output(see Output Modes):- term: rich shell formatting (more about this on the next step)
- term-debug: print formatting info for testing/debugging
- text: plain text, no styling
- auto: the default, rich term that degrades gracefully
- json, csv, yaml: automatic serialization of your data
What's next: Adding rich styling to make the output beautiful.
Your files now:
src/
├── main.rs # App::builder() setup
├── commands.rs # Commands enum with #[derive(Dispatch)]
├── handlers.rs # list(), add() returning HandlerResult
└── templates/
├── list.jinja
└── add.jinja
8. Make the Output Awesome
Let's transform that mono-typed, monochrome string into a richer and more useful UI. Borrowing from web apps setup, we keep the content in a template file, and we define styles in a stylesheet file.
See Rendering System for full styling documentation.
8.1 Create the stylesheet
Create src/styles/default.css:
/* Styles for completed todos */
.done {
text-decoration: line-through;
color: gray;
}
/* Style for todo index numbers */
.index {
color: yellow;
}
/* Style for pending todos */
.pending {
font-weight: bold;
color: white;
}
/* Adaptive style for messages */
.message {
color: cyan;
}
@media (prefers-color-scheme: light) {
.pending { color: black; }
}
@media (prefers-color-scheme: dark) {
.pending { color: white; }
}
Or if you prefer YAML (src/styles/default.yaml):
done: strikethrough, gray
index: yellow
pending:
bold: true
fg: white
light:
fg: black
dark:
fg: white
message: cyan
Verify: The file exists at
src/styles/default.cssorsrc/styles/default.yaml.
8.2 Add style tags to your template
Update src/templates/list.jinja with style tags:
{% if message %}[message]{{ message }}[/message]
{% endif %}
{% for todo in todos %}
[index]{{ loop.index }}.[/index] [{{ todo.status }}]{{ todo.title }}[/{{ todo.status }}]
{% endfor %}
The style tags use BBCode-like syntax: [style-name]content[/style-name]
Notice how we use [{{ todo.status }}] dynamically - if todo.status is "done", it applies the .done style; if it's "pending", it applies the .pending style.
Verify: The template file is updated.
8.3 Wire up styles in AppBuilder
Add the styles to your app builder:
#![allow(unused)] fn main() { let app = App::builder() .templates(embed_templates!("src/templates")) .styles(embed_styles!("src/styles")) // Load stylesheets .default_theme("default") // Use styles/default.css or default.yaml .commands(Commands::dispatch_config()) .build()?; }
Verify: Run
cargo build- it should compile without errors. Verify: Runtdoo list- you should see colored, styled output! Verify: Runtdoo list --output text- plain text, no colors.
Now you're leveraging the core rendering design of Standout:
- File-based templates for content, and stylesheets for styles
- Custom template syntax with BBCode for markup styles
[style][/style] - Live reload: iterate through content and styling without recompiling
Intermezzo D: The Full Setup Is Done
What you achieved: A fully styled, testable, multi-format CLI.
What's now possible:
- Rich terminal output with colors, bold, strikethrough
- Automatic light/dark mode adaptation
- JSON/YAML/CSV output for scripting and testing
- Hot reload of templates and styles during development
- Unit testable logic handlers
Your final files:
src/
├── main.rs # App::builder() setup
├── commands.rs # Commands enum with #[derive(Dispatch)]
├── handlers.rs # list(), add() returning HandlerResult
├── templates/
│ ├── list.jinja # with [style] tags
│ └── add.jinja
└── styles/
└── default.css # or default.yaml
For brevity's sake, we've ignored a bunch of finer and relevant points:
- The derive macros can set name mapping explicitly:
#[dispatch(handler = custom_fn, template = "custom.jinja")] - There are pre-dispatch, post-dispatch and post-render hooks (see Execution Model)
- Standout exposes its primitives as libraries for custom usage (see Render Only)
- Powerful tabular layouts via the
colfilter (see Tabular Layout) - A help topics system for rich documentation (see Topics System)
Aside from exposing the library primitives, Standout leverages best-in-breed crates like MiniJinja and console::Style under the hood. The lock-in is really negligible: you can use Standout's BB parser or swap it, manually dispatch handlers, and use the renderers directly in your clap dispatch.
Mutable Handlers (LocalApp)
If your application logic uses &mut self methods—common with database connections, file caches, or in-memory indices—you can use LocalApp instead of App:
use standout::cli::{LocalApp, Output}; use standout::embed_templates; struct PadStore { index: HashMap<Uuid, Metadata>, } impl PadStore { fn complete(&mut self, id: Uuid) -> Result<()> { // This needs &mut self self.index.get_mut(&id).unwrap().completed = true; Ok(()) } } fn main() -> anyhow::Result<()> { let mut store = PadStore::load()?; LocalApp::builder() .templates(embed_templates!("src/templates")) .command("complete", |m, ctx| { let id = m.get_one::<Uuid>("id").unwrap(); store.complete(*id)?; // &mut store works! Ok(Output::Silent) }, "") .command("list", |m, ctx| { // Even read-only handlers work with LocalApp Ok(Output::Render(store.list())) }, "{{ items }}") .build()? .run(Cli::command(), std::env::args()); Ok(()) }
Key differences from App:
LocalApp::builder()acceptsFnMutclosures (not justFn)- Handlers can capture
&mutreferences to state - No
Send + Syncrequirement on handlers app.run()takes&mut selfinstead of&self
Use LocalApp when:
- Your API has
&mut selfmethods - You want to avoid
Arc<Mutex<_>>wrappers - Your CLI is single-threaded (the common case)
See Handler Contract for the full comparison.
Appendix: Common Errors and Troubleshooting
- Template not found
- Error:
template 'list' not found - Cause: The template path in
embed_templates!doesn't match your file structure. - Fix: Ensure the path is relative to your
Cargo.toml, e.g.,embed_templates!("src/templates")and that the file is namedlist.jinja,list.j2, orlist.txt.
- Error:
- Style not applied
- Symptom: Text appears but without colors/formatting.
- Cause: Style name in template doesn't match stylesheet.
- Fix: Check that
[mystyle]in your template matches.mystylein CSS ormystyle:in YAML. Run with--output term-debugto see style tag names.
- Handler not called
- Symptom: Command runs but nothing happens or wrong handler runs.
- Cause: Command name mismatch between clap enum variant and handler function.
- Fix: Ensure enum variant
Listmaps to functionhandlers::list(snake_case conversion). Or use explicit mapping:#[dispatch(handler = my_custom_handler)]
- JSON output is empty or wrong
- Symptom:
--output jsonproduces unexpected results. - Cause:
Serializederive is missing or field names don't match template expectations. - Fix: Ensure all types in your result implement
Serialize. Use#[serde(rename_all = "lowercase")]for consistent naming.
- Symptom:
- Styles not loading
- Error:
theme not found: default - Cause: Stylesheet file missing or wrong path.
- Fix: Ensure
src/styles/default.cssordefault.yamlexists. Checkembed_styles!path matches your file structure.
- Error:
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:
- Rendering System - complete rendering API reference
- Output Modes - all output format options
- Introduction to Standout - end-to-end adoption guide
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 (
#ff6b35or[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:
-
Separation of concerns: Logic returns data. Templates define structure. Styles control appearance.
-
Fast iteration: Hot reload means edit-and-see in seconds, not minutes. This changes what's practical.
-
Familiar tools: MiniJinja for templates (Jinja syntax), CSS or YAML for styles. No new languages to learn.
-
Graceful degradation: One template serves rich terminals, plain pipes, and everything in between.
-
Structured output for free: JSON, YAML, and CSV outputs work automatically from your serializable types.
-
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.
Introduction to Tabular
Polished terminal output requires two things: good formatting (see Rendering System) 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.
Tabular is designed to minimize grunt work. It offers a declarative API, template-based syntax, and derive macros to link your existing data types directly to column definitions. Complex tables with complex types can be handled declaratively, with precise control over layout and minimal code.
In this guide, we will walk our way up from a simpler table to a more complex one, exploring the available features of Tabular.
See Also:
- Tabular Reference - complete API reference
- Rendering System - templates and styles in depth
Our Example: tdoo
We'll build the output for tdoo list, a command that shows todos. This is a perfect Tabular use case: each todo 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() { #[derive(Clone, Serialize)] #[serde(rename_all = "lowercase")] pub enum Status { Pending, Done } #[derive(Clone, Serialize)] struct Todo { title: String, status: Status, } let todos = vec![ Todo { title: "Implement user authentication".into(), status: Status::Pending }, Todo { title: "Fix payment gateway timeout".into(), status: Status::Pending }, Todo { title: "Update documentation for API v2".into(), status: Status::Done }, Todo { 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 todo in todos %}
{{ loop.index }}. {{ todo.title }} {{ todo.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 todo in todos %}
{{ loop.index | col(4) }} {{ todo.status | col(10) }} {{ todo.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 todo in todos %}
{{ t.row([loop.index, todo.status, todo.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 todo 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 todo in todos %}
{{ t.row([loop.index, todo.status | style_as(todo.status), todo.title]) }}
{% endfor %}
The style_as filter wraps the value in style tags: [done]done[/done]. Standout's 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 todos 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 [todo.title, todo.status, ...]? If your column names match your struct fields, use row_from():
{% set t = tabular([
{"name": "title", "width": "fill"},
{"name": "status", "width": 10}
]) %}
{% for todo in todos %}
{{ t.row_from(todo) }}
{% endfor %}
Tabular extracts todo.title, todo.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 todo in todos %}
{{ t.row([loop.index, todo.status, todo.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 our polished todo 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 todo in todos %}
{{ t.row([loop.index, todo.status | style_as(todo.status), todo.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::tabular::{TabularSpec, Col, Table, BorderStyle}; let spec = TabularSpec::builder() .column(Col::fixed(4).header("#").style("muted")) .column(Col::fixed(10).header("Status")) .column(Col::fill().header("Title").truncate_middle()) .separator(" │ ") .build(); let table = Table::new(spec, 80) .header_from_columns() .header_style("bold") .border(BorderStyle::Rounded); // Render the full table let output = table.render(&data); // Or render parts manually for custom logic println!("{}", table.header_row()); println!("{}", table.separator_row()); for (i, todo) in todos.iter().enumerate() { println!("{}", table.row(&[&(i + 1).to_string(), &todo.status.to_string(), &todo.title])); } println!("{}", table.bottom_border()); }
Derive Macros: Type-Safe Table Definitions
Instead of manually building TabularSpec instances, you can use derive macros to generate them from struct annotations. This keeps your column definitions co-located with your data types and ensures they stay in sync.
#[derive(Tabular)] - Generate Spec from Struct
Add #[col(...)] attributes to your struct fields to define column properties:
#![allow(unused)] fn main() { use standout::tabular::{Tabular, TabularRow, Table, BorderStyle}; use serde::Serialize; #[derive(Clone, Serialize)] #[serde(rename_all = "lowercase")] pub enum Status { Pending, Done } #[derive(Clone, Serialize, Tabular, TabularRow)] #[tabular(separator = " │ ")] struct Todo { #[col(width = "fill", header = "Title", overflow = "truncate", truncate_at = "middle")] title: String, #[col(width = 10, header = "Status")] status: Status, } }
Now create formatters and tables directly from the type:
#![allow(unused)] fn main() { // Create a table using the derived spec let table = Table::from_type::<Todo>(80) .header_from_columns() .border(BorderStyle::Rounded); // Render rows using the TabularRow trait (no JSON serialization) for todo in &todos { println!("{}", table.row_from_trait(todo)); } }
Available Field Attributes
| Attribute | Type | Description |
|---|---|---|
width | 8, "fill", "2fr" | Column width strategy |
min, max | usize | Bounded width range |
align | "left", "right", "center" | Text alignment within cell |
anchor | "left", "right" | Column position in row |
overflow | "truncate", "wrap", "clip", "expand" | How to handle long content |
truncate_at | "end", "start", "middle" | Where to truncate |
style | string | Style name for entire column |
style_from_value | flag | Use cell value as style name |
header | string | Column header text |
null_repr | string | Representation for null values |
key | string | Override field name for extraction |
skip | flag | Exclude field from table |
Container Attributes
| Attribute | Description |
|---|---|
separator | Column separator (default: " ") |
prefix | Row prefix |
suffix | Row suffix |
Using with Templates
The derived spec can be injected into templates using helper functions:
#![allow(unused)] fn main() { use standout::tabular::filters::{table_from_type, register_tabular_filters}; use minijinja::{context, Environment}; let mut env = Environment::new(); register_tabular_filters(&mut env); // Create a table from the derived spec let table = table_from_type::<Todo>(80, BorderStyle::Light, true); // Use in template context env.add_template("todos", r#" {{ tbl.top_border() }} {{ tbl.header_row() }} {{ tbl.separator_row() }} {% for todo in todos %}{{ tbl.row([todo.title, todo.status]) }} {% endfor %}{{ tbl.bottom_border() }} "#)?; let output = env.get_template("todos")?.render(context! { tbl => table, todos => todo_data, })?; }
Why Two Macros?
#[derive(Tabular)]generates theTabularSpec(column definitions, widths, styles)#[derive(TabularRow)]generates efficient row extraction (field values to strings)
You can use them independently:
- Use only
Tabularwithrow_from()to keep serde-based extraction - Use only
TabularRowwith manually-built specs for maximum control - Use both together for the best type safety and performance
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
- Use derive macros -
#[derive(Tabular, TabularRow)]for type-safe definitions
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 Tabular Reference.
Fast Paced intro to your First Standout Based Command
This is a terse and direct how to for more experienced developers or at least the ones in a hurry. It skimps rationale, design and other useful bits you can read from the longer form version
Prerequisites
A cli app, that uses clap for arg parsing. A command fucntion that is pure logic, that is , returns the result, and does not print to stdout or format output.
For this guide's purpose we'll use a ficticious "list" command of our todo list manager
The Core: A pure function logic handler
The logic handler: receives parsed cli args, and returns a serializable data structure:
#![allow(unused)] fn main() { pub fn list(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<TodoResult> {} }
Making it oustanding
1. The File System
Create a templates/list.jinja and styles/default.css:
src/
├── handlers.rs # where list it
├── templates/ # standout will match templates name against rel. paths from temp root, here
├── list.jinja # the template to render list, name matched against the command name
├── styles/ # likewise for themes, this sets a theme called "default"
├── default.css # the default style for the command, filename will be the theme name
2. Define your styles
.done {
text-decoration: line-through;
color: gray;
}
.pending {
font-weight: bold;
color: white;
}
.index {
color: yellow;
}
3. Write your template
{% if message %}
[message]{{ message }} [/message]
{% endif %}
{% for todo in todos %}
[index]{{ loop.index }}.[/index] [{{ todo.status }}]{{ todo.title }}[/{{ todo.status }}]
{% endfor %}
4. Putting it all together
Configure the app:
#![allow(unused)] fn main() { let app = App::builder() .templates(embed_templates!("src/templates")) // Sets the root template path, hot relead for dev, embeded in release .styles(embed_styles!("src/styles")) // Likewise the styles root .default_theme("default") // Use styles/default.css or default.yaml .commands(Commands::dispatch_config()) // Register handlers from derive macro .build()?; }
Connect your logic to a command name and template :
#![allow(unused)] fn main() { #[dispatch(handlers = handlers)] pub enum Commands { ... list, } }
And finally, run in main, the autodispatcher:
#![allow(unused)] fn main() { match app.run(Cli::command(), std::env::args()) { // If you've got other commands on vanilla manual dispatch, call it for unported commands RunResult::NoMatch(matches) => legacy_dispatch(matches), // Your existing handler } }
Topics
In-depth documentation for specific Standout systems and use cases.
Core Concepts
Handler Contract
The interface between your logic and Standout. Covers the Handler trait, HandlerResult, the Output enum (Render, Silent, Binary), and CommandContext. Essential reading for understanding how handlers return data to be rendered.
Rendering System
How Standout transforms data into styled terminal output. Covers the two-pass architecture (MiniJinja + BBParser), style tags, themes, template filters, context injection, and structured output modes.
Output Modes
The --output flag and OutputMode enum. Covers auto/term/text modes for terminal output, structured modes (JSON, YAML, XML, CSV), file output, and how to access the mode in handlers.
Execution Model
The request lifecycle from CLI input to rendered output. Covers the pipeline (parsing, dispatch, handler, hooks, rendering), command paths, the hooks system (pre-dispatch, post-dispatch, post-output), and default command behavior.
Configuration
App Configuration
The AppBuilder API for configuring your application. Covers embedding templates and styles, theme selection, command registration, hooks, context injection, flag customization, and the complete setup workflow.
Topics System
Adding help topics to your CLI. Covers the Topic struct, TopicRegistry, loading topics from directories, help integration, pager support, and custom rendering.
Layout
Tabular Layout
Creating aligned, readable output for lists and tables. Covers the col filter, tabular() and table() functions, flexible widths, overflow handling, column styling, borders, and the Rust API.
Standalone Usage
Partial Adoption
Migrating an existing CLI to Standout incrementally. Covers using run with fallback dispatch, progressive command migration, and full adoption patterns.
Render Only
Using Standout's rendering layer without CLI integration. Covers standalone rendering functions, building themes programmatically, template validation, and context injection for non-CLI use cases.
The Handler Contract
Handlers are where your application logic lives. Standout's handler contract is designed to be explicit rather than permissive. By enforcing serializable return types and clear ownership semantics, the framework guarantees that your code remains testable and decoupled from output formatting.
Instead of fighting with generic Any types or global state, you work with a clear contract: inputs are references, output is a Result.
See also:
- Output Modes for how the output enum interacts with formats.
Handler Modes
Standout supports two handler modes to accommodate different use cases:
| Aspect | Handler (default) | LocalHandler |
|---|---|---|
| App type | App | LocalApp |
| Self reference | &self | &mut self |
| Closure type | Fn | FnMut |
| Thread bounds | Send + Sync | None |
| State mutation | Via interior mutability | Direct |
| Use case | Libraries, async, multi-threaded | Simple CLIs with mutable state |
Choose based on your needs:
-
AppwithHandler: Default. Use when handlers are stateless or use interior mutability (Arc<Mutex<_>>). Required for potential multi-threading. -
LocalAppwithLocalHandler: Use when your handlers need&mut selfaccess without wrapper types. Ideal for single-threaded CLIs.
The Handler Trait (Thread-safe)
#![allow(unused)] fn main() { pub trait Handler: Send + Sync { type Output: Serialize; fn handle(&self, matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Self::Output>; } }
Key constraints:
- Send + Sync required: Handlers may be called from multiple threads
- Output must be Serialize: Needed for JSON/YAML modes and template context
- Immutable references: Handlers cannot modify arguments or context
Implementing the trait directly is useful when your handler needs internal state—database connections, configuration, etc. For stateless logic, closure handlers are more convenient.
Closure Handlers
Most handlers are simple closures:
#![allow(unused)] fn main() { App::builder() .command("list", |matches, ctx| { let verbose = matches.get_flag("verbose"); let items = storage::list()?; Ok(Output::Render(ListResult { items, verbose })) }, "list.j2") }
The closure signature:
#![allow(unused)] fn main() { fn(&ArgMatches, &CommandContext) -> HandlerResult<T> where T: Serialize + Send + Sync }
Closures must be Fn (not FnMut or FnOnce) because Standout may call them multiple times in certain scenarios.
The LocalHandler Trait (Mutable State)
When your handlers need &mut self access—common with database connections, file caches, or in-memory indices—use LocalHandler with LocalApp:
#![allow(unused)] fn main() { pub trait LocalHandler { type Output: Serialize; fn handle(&mut self, matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Self::Output>; } }
Key differences from Handler:
- No Send + Sync: Handlers don't need to be thread-safe
- Mutable self:
&mut selfallows direct state modification - FnMut closures: Captured variables can be mutated
When to Use LocalHandler
Use LocalHandler when:
- Your API uses
&mut selfmethods (common for file/database operations) - You want to avoid
Arc<Mutex<_>>wrappers - Your CLI is single-threaded (the typical case)
#![allow(unused)] fn main() { use standout::cli::{LocalApp, LocalHandler, Output, HandlerResult, CommandContext}; struct Database { connection: Connection, cache: HashMap<String, Record>, } impl Database { fn query_mut(&mut self, sql: &str) -> Result<Vec<Row>, Error> { // Needs &mut self because it updates the cache if let Some(cached) = self.cache.get(sql) { return Ok(cached.clone()); } let result = self.connection.execute(sql)?; self.cache.insert(sql.to_string(), result.clone()); Ok(result) } } impl LocalHandler for Database { type Output = Vec<Row>; fn handle(&mut self, matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Vec<Row>> { let query = matches.get_one::<String>("query").unwrap(); let rows = self.query_mut(query)?; Ok(Output::Render(rows)) } } }
Local Closure Handlers
LocalApp::builder().command() accepts FnMut closures:
#![allow(unused)] fn main() { let mut db = Database::connect()?; LocalApp::builder() .command("query", |matches, ctx| { let sql = matches.get_one::<String>("sql").unwrap(); let rows = db.query_mut(sql)?; // &mut db works! Ok(Output::Render(rows)) }, "{{ rows }}") .build()? .run(cmd, args); }
This is the primary use case: capturing mutable references in closures without interior mutability wrappers.
HandlerResult
HandlerResult<T> is a standard Result type:
#![allow(unused)] fn main() { pub type HandlerResult<T> = Result<Output<T>, anyhow::Error>; }
The ? operator works naturally for error propagation:
#![allow(unused)] fn main() { fn list_handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Items> { let items = storage::load()?; // Propagates errors let filtered = filter_items(&items)?; // Propagates errors Ok(Output::Render(Items { filtered })) } }
Errors become the command output—Standout formats and displays them appropriately.
The Output Enum
Output<T> represents what a handler produces:
#![allow(unused)] fn main() { pub enum Output<T: Serialize> { Render(T), Silent, Binary { data: Vec<u8>, filename: String }, } }
Output::Render(T)
The common case. Data is serialized to JSON, passed to the template engine, and rendered with styles:
#![allow(unused)] fn main() { #[derive(Serialize)] struct ListResult { items: Vec<Item>, total: usize, } fn list_handler(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<ListResult> { let items = storage::list()?; Ok(Output::Render(ListResult { total: items.len(), items, })) } }
In structured output modes (--output json), the template is skipped and data serializes directly—same handler code, different output format.
Output::Silent
No output produced. Useful for commands with side effects only:
#![allow(unused)] fn main() { fn delete_handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<()> { let id: &String = matches.get_one("id").unwrap(); storage::delete(id)?; Ok(Output::Silent) } }
Silent behavior in the pipeline:
- Post-output hooks still receive
RenderedOutput::Silent(they can transform it) - If
--output-fileis set, nothing is written - Nothing prints to stdout
The type parameter for Output::Silent is often () but can be any Serialize type—it's never used.
Output::Binary
Raw bytes written to a file. Useful for exports, archives, or generated files:
#![allow(unused)] fn main() { fn export_handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<()> { let data = generate_report()?; let pdf_bytes = render_to_pdf(&data)?; Ok(Output::Binary { data: pdf_bytes, filename: "report.pdf".into(), }) } }
The filename is used as a literal file path. Standout writes the bytes using std::fs::write() and prints a confirmation to stderr. The filename can be:
- Relative:
"output/report.pdf" - Absolute:
"/tmp/report.pdf" - Dynamic:
format!("report-{}.pdf", timestamp)
Binary output bypasses the template engine entirely.
CommandContext
CommandContext provides execution environment information:
#![allow(unused)] fn main() { pub struct CommandContext { pub output_mode: OutputMode, pub command_path: Vec<String>, } }
output_mode: The resolved output format (Term, Text, Json, etc.). Handlers can inspect this to adjust behavior—for example, skipping interactive prompts in JSON mode:
#![allow(unused)] fn main() { fn interactive_handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Data> { let confirmed = if ctx.output_mode.is_structured() { true // Non-interactive in JSON mode } else { prompt_user("Continue?")? }; // ... } }
command_path: The subcommand chain as a vector, e.g., ["db", "migrate"]. Useful for logging or conditional logic.
See Execution Model for more on command paths.
CommandContext is intentionally minimal. Application-specific context (config, connections) should be captured in struct handlers or closures:
#![allow(unused)] fn main() { struct MyHandler { db: DatabasePool, config: AppConfig, } impl Handler for MyHandler { type Output = Data; fn handle(&self, matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Data> { let result = self.db.query(...)?; Ok(Output::Render(result)) } } }
Accessing CLI Arguments
The ArgMatches parameter provides access to parsed arguments through clap's standard API:
#![allow(unused)] fn main() { fn handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Data> { // Flags let verbose = matches.get_flag("verbose"); // Required options let name: &String = matches.get_one("name").unwrap(); // Optional values let limit: Option<&u32> = matches.get_one("limit"); // Multiple values let tags: Vec<&String> = matches.get_many("tags") .map(|v| v.collect()) .unwrap_or_default(); Ok(Output::Render(Data { ... })) } }
For subcommands, you receive the ArgMatches for your specific command, not the root. Standout navigates to the deepest match before calling your handler.
The #[dispatch] Macro
For applications with many commands, the #[dispatch] attribute macro generates registration from an enum:
#![allow(unused)] fn main() { #[derive(Dispatch)] enum Commands { List, Add, Remove, } }
This generates a dispatch_config() method that registers handlers. Variant names are converted to snake_case command names:
List→"list"ListAll→"list_all"
The macro expects handler functions named after the variant:
#![allow(unused)] fn main() { fn list(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<ListOutput> { ... } fn add(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<AddOutput> { ... } fn remove(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<RemoveOutput> { ... } }
Variant attributes for customization:
#![allow(unused)] fn main() { #[derive(Dispatch)] enum Commands { #[dispatch(handler = custom_list_fn)] // Override handler function List, #[dispatch(template = "custom/add.j2")] // Override template path Add, #[dispatch(pre_dispatch = validate_auth)] // Add hook Remove, #[dispatch(skip)] // Don't register this variant Internal, #[dispatch(nested)] // This is a subcommand enum Db(DbCommands), } }
The nested attribute is required for subcommand enums—it's not inferred from tuple variants.
Testing Handlers
Because handlers are pure functions with explicit inputs and outputs, they're straightforward to test:
#![allow(unused)] fn main() { #[test] fn test_list_handler() { let cmd = Command::new("test").arg(Arg::new("verbose").long("verbose").action(ArgAction::SetTrue)); let matches = cmd.try_get_matches_from(["test", "--verbose"]).unwrap(); let ctx = CommandContext { output_mode: OutputMode::Term, command_path: vec!["list".into()], }; let result = list_handler(&matches, &ctx); assert!(result.is_ok()); if let Ok(Output::Render(data)) = result { assert!(data.verbose); } } }
No mocking frameworks needed—construct ArgMatches with clap, create a CommandContext, call your handler, assert on the result.
Testing LocalHandlers
LocalHandler tests work the same way, but use &mut self:
#![allow(unused)] fn main() { #[test] fn test_local_handler_state_mutation() { struct Counter { count: u32 } impl LocalHandler for Counter { type Output = u32; fn handle(&mut self, _m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<u32> { self.count += 1; Ok(Output::Render(self.count)) } } let mut handler = Counter { count: 0 }; let cmd = Command::new("test"); let matches = cmd.try_get_matches_from(["test"]).unwrap(); let ctx = CommandContext { output_mode: OutputMode::Term, command_path: vec!["count".into()], }; // State accumulates across calls let _ = handler.handle(&matches, &ctx); let _ = handler.handle(&matches, &ctx); let result = handler.handle(&matches, &ctx); assert!(matches!(result, Ok(Output::Render(3)))); } }
Choosing Between Handler and LocalHandler
| Your situation | Use |
|---|---|
| Stateless handlers | App + closures |
State with Arc<Mutex<_>> already | App + Handler trait |
API with &mut self methods | LocalApp + LocalHandler |
| Building a library | App (consumers might need thread safety) |
| Simple single-threaded CLI | Either works; LocalApp avoids wrapper types |
The key insight: CLIs are fundamentally single-threaded (parse → run one handler → output → exit). The Send + Sync requirement in Handler is conventional, not strictly necessary. LocalHandler removes this requirement for simpler code when thread safety isn't needed.
The Rendering System
Standout's rendering layer separates presentation from logic by using a two-pass architecture. This allows you to use standard tools (MiniJinja) for structure while keeping styling strictly separated and easy to debug.
Instead of mixing ANSI codes into your logic or templates, you define what something is (semantic tags like [error]) and let the theme decide how it looks.
Two-Pass Rendering
Templates are processed in two distinct passes:
Pass 1 - MiniJinja: Standard template processing. Variables are substituted, control flow executes, filters apply.
Pass 2 - BBParser: Style tag processing. Bracket-notation tags are converted to ANSI escape codes (or stripped, depending on output mode).
Template: [title]{{ name }}[/title] has {{ count }} items
Data: { name: "Report", count: 42 }
After Pass 1: [title]Report[/title] has 42 items
After Pass 2: \x1b[1;32mReport\x1b[0m has 42 items
This separation means:
- Template logic (loops, conditionals) is handled by MiniJinja—a mature, well-documented engine
- Style application is a simple, predictable transformation
- You can debug each pass independently
Style Tags
Style tags use BBCode-like bracket notation:
[style-name]content to style[/style-name]
The style-name must match a style defined in the theme. Tags can:
- Nest:
[outer][inner]text[/inner][/outer] - Span multiple lines
- Contain template logic:
[title]{% if x %}{{ x }}{% endif %}[/title]
The tag syntax was chosen over Jinja filters because it reads naturally and doesn't interfere with Jinja's own syntax.
What Happens Based on OutputMode
- Term: Tags replaced with ANSI escape codes
- Text: Tags stripped, plain text remains
- TermDebug: Tags kept as literals (
[name]...[/name]) for debugging - Structured (JSON, etc.): Template not used—data serializes directly
Unknown Style Tags
When a tag references a style not in the theme, Standout prioritizes developer visibility without crashing production apps.
-
Term mode: Unknown tags get a
?marker:[unknown?]text[/unknown?] -
Text mode: Tags stripped like any other
-
TermDebug mode: Tags preserved as-is
The ? marker helps catch typos during development. For production, use validate_template() at startup:
#![allow(unused)] fn main() { let result = validate_template(template, &sample_data, &theme); if let Err(e) = result { eprintln!("Template errors: {}", e); std::process::exit(1); } }
Themes and Styles
A Theme is a named collection of styles mapping style names to console formatting.
See App Configuration for how to embed and load themes.
Programmatic Themes
#![allow(unused)] fn main() { let theme = Theme::new() .add("title", Style::new().bold().cyan()) .add("muted", Style::new().dim()) .add("error", Style::new().red().bold()) .add("disabled", "muted"); // Alias }
YAML Stylesheets
For file-based configuration:
# Full attribute form
header:
fg: cyan
bold: true
# Shorthand (space or comma separated)
accent: cyan
emphasis: bold
warning: "yellow bold"
error: bold, red, italic
# Alias (references another style)
disabled: muted
# Adaptive (different in light/dark mode)
panel:
fg: gray
bold: true
light:
fg: black
dark:
fg: white
CSS Stylesheets
If you prefer standard CSS syntax over YAML, Standout supports a subset of CSS Level 3 tailored for terminals:
/* Selectors map to style names */
.panel {
color: gray;
font-weight: bold;
}
/* Shortcuts work as expected */
.error { color: red; font-weight: bold; }
/* Adaptive styles via media queries */
@media (prefers-color-scheme: light) {
.panel { color: black; }
}
@media (prefers-color-scheme: dark) {
.panel { color: white; }
}
This is ideal for developers who want to leverage existing knowledge and tooling (syntax highlighting, linters) for their CLI themes.
Supported Attributes
Colors: fg, bg
Text attributes: bold, dim, italic, underline, blink, reverse, hidden, strikethrough
Color Formats
fg: red # Named (16 ANSI colors)
fg: bright_green # Bright variants
fg: 208 # 256-color palette
fg: "#ff6b35" # RGB hex
fg: [255, 107, 53] # RGB array
Style Aliasing
Aliases let semantic names in templates resolve to visual styles:
title:
fg: cyan
bold: true
commit-message: title # Alias
section-header: title # Another alias
Benefits:
- Templates use meaningful names (
[commit-message]) - Change one definition, update all aliases
- Styling stays flexible without template changes
Aliases can chain (a → b → c → concrete style). Cycles are detected and rejected.
Adaptive Styles
Themes can respond to the OS light/dark mode:
#![allow(unused)] fn main() { theme.add_adaptive( "panel", Style::new().bold(), // base (shared) Some(Style::new().fg(Color::Black)), // light mode override Some(Style::new().fg(Color::White)), // dark mode override ) }
In YAML:
panel:
bold: true
light:
fg: black
dark:
fg: white
The base provides shared attributes. Mode-specific overrides merge with base—Some replaces, None preserves.
Standout auto-detects the OS color scheme. Override for testing:
#![allow(unused)] fn main() { set_theme_detector(|| ColorMode::Dark); }
Template Filters
Beyond MiniJinja's built-ins, Standout adds formatting filters:
Column Formatting
{{ value | col(10) }}
{{ value | col(20, align='right') }}
{{ value | col(15, truncate='middle', ellipsis='...') }}
Arguments: width, align (left/right/center), truncate (end/start/middle), ellipsis
Padding
{{ "42" | pad_left(8) }} {# " 42" #}
{{ "hi" | pad_right(8) }} {# "hi " #}
{{ "hi" | pad_center(8) }} {# " hi " #}
Truncation
{{ long_text | truncate_at(20) }}
{{ path | truncate_at(30, 'middle', '...') }}
Display Width
{% if value | display_width > 20 %}...{% endif %}
Returns visual width (handles Unicode—CJK characters count as 2).
Context Injection
Context injection adds values to the template beyond handler data.
Static Context
Fixed values set at configuration time:
#![allow(unused)] fn main() { App::builder() .context("version", "1.0.0") .context("app_name", "MyApp") }
Dynamic Context
Computed at render time:
#![allow(unused)] fn main() { App::builder() .context_fn("terminal_width", |ctx| { Value::from(ctx.terminal_width.unwrap_or(80)) }) .context_fn("is_color", |ctx| { Value::from(ctx.output_mode.should_use_color()) }) }
In templates:
{{ app_name }} v{{ version }}
{% if terminal_width > 100 %}...{% endif %}
When handler data and context have the same key, handler data wins. Context is supplementary, not an override mechanism.
Structured Output Modes
Structured modes (Json, Yaml, Xml, Csv) bypass template rendering entirely:
OutputMode::Json → serde_json::to_string_pretty(data)
OutputMode::Yaml → serde_yaml::to_string(data)
OutputMode::Xml → quick_xml::se::to_string(data)
OutputMode::Csv → flatten and format as CSV
This means:
- Template content is ignored
- Style tags never apply
- Context injection is skipped
- What you serialize is what you get
Same handler code, same data types—just different output format based on --output.
Render Functions
For using the rendering layer without CLI integration:
Basic Rendering
#![allow(unused)] fn main() { use standout::{render, Theme}; let theme = Theme::new().add("ok", Style::new().green()); let output = render( "[ok]{{ message }}[/ok]", &Data { message: "Success".into() }, &theme, )?; }
With Output Mode
#![allow(unused)] fn main() { use standout::{render_with_output, OutputMode}; // Honor --output flag value let output = render_with_output(template, &data, &theme, OutputMode::Text)?; }
With Extra Variables
#![allow(unused)] fn main() { use standout::{render_with_vars, OutputMode}; use std::collections::HashMap; // Inject simple key-value pairs into the template context let mut vars = HashMap::new(); vars.insert("version", "1.0.0"); let output = render_with_vars( "{{ name }} v{{ version }}", &data, &theme, OutputMode::Text, vars, )?; }
Auto-Dispatch (Template vs Serialize)
#![allow(unused)] fn main() { use standout::render_auto; // For Term/Text: renders template // For Json/Yaml/etc: serializes data directly let output = render_auto(template, &data, &theme, OutputMode::Json)?; }
Full Control
#![allow(unused)] fn main() { use standout::{render_with_mode, ColorMode}; // Explicit output mode AND color mode (for tests) let output = render_with_mode( template, &data, &theme, OutputMode::Term, ColorMode::Dark, )?; }
Rendering Prelude
For convenient imports when using the rendering layer standalone:
#![allow(unused)] fn main() { use standout::rendering::prelude::*; let theme = Theme::new() .add("title", Style::new().bold()); let output = render("[title]{{ name }}[/title]", &data, &theme)?; }
The prelude includes: render, render_auto, render_with_output, render_with_mode, render_with_vars, Theme, ColorMode, OutputMode, Renderer, and Style.
Hot Reloading
In debug builds, file-based templates are re-read from disk on each render. Edit templates without recompiling.
In release builds, templates are cached after first load.
This is automatic—no configuration needed. The Renderer struct tracks which templates are inline vs file-based and handles them appropriately.
Template Registry
Templates are resolved by name with priority:
- Inline templates (added via
add_template()) - Embedded templates (from
embed_templates!) - File templates (from
.templates_dir())
Supported extensions (in priority order): .jinja, .jinja2, .j2, .txt
When you request "config", the registry checks:
- Inline template named
"config" config.jinjain registered directoriesconfig.jinja2,config.j2,config.txt(lower priority)
Cross-directory collisions (same name in multiple dirs) raise an error. Same-directory collisions use extension priority.
Output Modes
Standout supports multiple output formats through a single handler because modern CLI tools serve two masters: human operators and machine automation.
The same handler logic produces styled terminal output for eyes, plain text for logs, or structured JSON for jq pipelines—controlled entirely by the user's --output flag. This frees you from writing separate "API" and "CLI" logic.
The OutputMode Enum
#![allow(unused)] fn main() { pub enum OutputMode { Auto, // Auto-detect terminal capabilities Term, // Always use ANSI escape codes Text, // Never use ANSI codes (plain text) TermDebug, // Keep style tags as [name]...[/name] Json, // Serialize as JSON (skip template) Yaml, // Serialize as YAML (skip template) Xml, // Serialize as XML (skip template) Csv, // Serialize as CSV (skip template) } }
Three categories:
Templated modes (Auto, Term, Text): Render the template, vary ANSI handling.
Debug mode (TermDebug): Render the template, keep tags as literals for inspection.
Structured modes (Json, Yaml, Xml, Csv): Skip the template entirely, serialize handler data directly.
Auto Mode
Auto is the default. It queries the terminal for color support:
#![allow(unused)] fn main() { Term::stdout().features().colors_supported() }
If colors are supported, Auto behaves like Term (ANSI codes applied). If not, Auto behaves like Text (tags stripped).
This detection happens at render time, not startup. Piping output to a file or another process typically disables color support, so:
myapp list # Colors (if terminal supports)
myapp list > file.txt # No colors (not a TTY)
myapp list | less # No colors (pipe)
The --output Flag
Standout adds a global --output flag accepting these values:
myapp list --output=auto # Default
myapp list --output=term # Force ANSI codes
myapp list --output=text # Force plain text
myapp list --output=term-debug # Show style tags
myapp list --output=json # JSON serialization
myapp list --output=yaml # YAML serialization
myapp list --output=xml # XML serialization
myapp list --output=csv # CSV serialization
The flag is global—it applies to all subcommands.
Term vs Text
Term: Always applies ANSI escape codes, even when piping:
myapp list --output=term > colored.txt
Useful when you want to preserve colors for later display (e.g., less -R).
Text: Never applies ANSI codes:
myapp list --output=text
Useful for clean output regardless of terminal capabilities, or when processing output with other tools.
TermDebug Mode
TermDebug preserves style tags instead of converting them:
Template: [title]Hello[/title]
Output: [title]Hello[/title]
Use cases:
- Debugging template issues
- Verifying style tag placement
- Automated testing of template output
Unlike Term mode, unknown tags don't get the ? marker in TermDebug.
Structured Modes
Structured modes bypass the template entirely. Handler data is serialized directly:
#![allow(unused)] fn main() { #[derive(Serialize)] struct ListOutput { items: Vec<Item>, total: usize, } fn list_handler(...) -> HandlerResult<ListOutput> { Ok(Output::Render(ListOutput { items, total: items.len() })) } }
myapp list --output=json
{
"items": [...],
"total": 42
}
Same handler, same types—different output format. This enables:
- Machine-readable output for scripts
- Integration with other tools (
jq, etc.) - API-like behavior from CLI apps
CSV Output
CSV mode flattens nested JSON automatically. For more control, use FlatDataSpec.
See Tabular Layout for detailed CSV configuration.
#![allow(unused)] fn main() { let spec = FlatDataSpec::builder() .column(Column::new(Width::Fixed(10)).key("name").header("Name")) .column(Column::new(Width::Fixed(10)).key("meta.role").header("Role")) .build(); render_auto_with_spec(template, &data, &theme, OutputMode::Csv, Some(&spec))? }
The key field uses dot notation for nested paths ("meta.role" extracts data["meta"]["role"]).
File Output
The --output-file-path flag redirects output to a file:
myapp list --output-file-path=results.txt
myapp list --output=json --output-file-path=data.json
Behavior:
- Text output: written to file, nothing printed to stdout
- Binary output: written to file (same as without flag)
- Silent output: no-op
After writing to file, stdout output is suppressed to prevent double-printing.
Customizing Flags
Rename or disable the flags via AppBuilder:
#![allow(unused)] fn main() { App::builder() .output_flag(Some("format")) // --format instead of --output .output_file_flag(Some("out")) // --out instead of --output-file-path .build()? }
#![allow(unused)] fn main() { App::builder() .no_output_flag() // Disable --output entirely .no_output_file_flag() // Disable file output .build()? }
Accessing OutputMode in Handlers
CommandContext carries the resolved output mode:
#![allow(unused)] fn main() { fn handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Data> { if ctx.output_mode.is_structured() { // Skip interactive prompts in JSON mode } if ctx.output_mode == OutputMode::Csv { // Maybe adjust data structure for flat output } Ok(Output::Render(data)) } }
Helper methods:
#![allow(unused)] fn main() { ctx.output_mode.should_use_color() // True for Term, depends on terminal for Auto ctx.output_mode.is_structured() // True for Json, Yaml, Xml, Csv ctx.output_mode.is_debug() // True for TermDebug }
Rendering Without CLI
For standalone rendering with explicit mode:
#![allow(unused)] fn main() { use standout::{render_auto, OutputMode}; // Renders template for Term/Text, serializes for Json/Yaml let output = render_auto(template, &data, &theme, OutputMode::Json)?; }
The "auto" in render_auto refers to template-vs-serialize dispatch, not color detection.
For full control over both output mode and color mode:
#![allow(unused)] fn main() { use standout::{render_with_mode, ColorMode}; let output = render_with_mode( template, &data, &theme, OutputMode::Term, ColorMode::Dark, )?; }
Tabular Layout
Standout helps you create aligned, readable output for lists, logs, and tabular data.
Choose your path:
- Quick Start: Simple alignment with template filters
- Structured Layout: Multi-column specs for complex output
- Full Tables: Headers, borders, and separators
Quick Start: The col Filter
For simple alignment, use the col filter directly in templates:
{% for entry in entries %}
{{ entry.id | col(8) }} {{ entry.name | col(20) }} {{ entry.status | col(10) }}
{% endfor %}
Output:
abc123 Alice Johnson active
def456 Bob Smith pending
ghi789 Carol Williams done
The col filter:
- Pads short values to the specified width
- Truncates long values with
… - Handles Unicode correctly (CJK characters count as 2 columns)
Alignment
{{ value | col(10) }} {# Left-aligned (default) #}
{{ value | col(10, align="right") }} {# Right-aligned #}
{{ value | col(10, align="center") }} {# Centered #}
left......
....right.
..center..
Truncation Position
When content is too long, choose where to cut:
{{ path | col(15) }} {# "Very long pa…" (default: end) #}
{{ path | col(15, truncate="start") }} {# "…ng/path/file" #}
{{ path | col(15, truncate="middle") }} {# "Very l…h/file" #}
Truncate middle is useful for paths where both start and end matter.
Custom Ellipsis
{{ value | col(10, ellipsis="...") }} {# "Hello W..." instead of "Hello W…" #}
{{ value | col(10, ellipsis="→") }} {# "Hello Wor→" #}
Structured Layout
When you need consistent column widths across complex output, define a layout spec.
Defining Columns in Templates
Use the tabular() function to create a formatter:
{% set t = tabular([
{"name": "id", "width": 8},
{"name": "author", "width": 20},
{"name": "message", "width": "fill"},
{"name": "date", "width": 10, "align": "right"}
]) %}
{% for commit in commits %}
{{ t.row([commit.id, commit.author, commit.message, commit.date]) }}
{% endfor %}
Output:
a1b2c3d4 Alice Johnson Add new login feature 2024-01-15
e5f6g7h8 Bob Smith Fix authentication bug 2024-01-14
i9j0k1l2 Carol Williams Update dependencies 2024-01-13
Width Options
| Width | Meaning | Example |
|---|---|---|
8 | Exactly 8 columns | IDs, short codes |
{"min": 10} | At least 10, grows to fit | Names, titles |
{"min": 10, "max": 30} | Between 10 and 30 | Bounded growth |
"fill" | Takes remaining space | Descriptions |
"2fr" | 2 parts of remaining (vs 1fr) | Proportional |
{% set t = tabular([
{"name": "id", "width": 8}, {# Fixed #}
{"name": "name", "width": {"min": 10}}, {# Grows to fit #}
{"name": "desc", "width": "fill"}, {# Takes the rest #}
]) %}
Anchoring Columns
Put columns at the right edge:
{% set t = tabular([
{"name": "name", "width": 20},
{"name": "path", "width": "fill"},
{"name": "size", "width": 8, "anchor": "right"}, {# Stays at right edge #}
]) %}
Output:
document.txt /home/user/docs/ 1.2 MB
image.png /home/user/photos/vacation/ 4.5 MB
The size column is anchored to the right edge. The path column fills the gap.
Handling Long Content
Choose what happens when content exceeds the column width:
{% set t = tabular([
{"name": "path", "width": 30, "overflow": "truncate"}, {# Default: "Very long…" #}
{"name": "desc", "width": 30, "overflow": "wrap"}, {# Wrap to multiple lines #}
]) %}
Truncate (default)
{"overflow": "truncate"} {# Truncate at end #}
{"overflow": {"truncate": {"at": "middle"}}} {# Keep start and end #}
{"overflow": {"truncate": {"marker": "..."}}} {# Custom ellipsis #}
Wrap
Content wraps to multiple lines:
abc123 This is a very long active
description that wraps
to multiple lines
def456 Short description done
{"name": "desc", "width": 25, "overflow": "wrap"}
{"name": "desc", "width": 25, "overflow": {"wrap": {"indent": 2}}} {# Continuation indent #}
Extracting Fields from Objects
When column names match struct fields, use row_from():
{% set t = tabular([
{"name": "id", "width": 8},
{"name": "title", "width": 30},
{"name": "status", "width": 10}
]) %}
{% for item in items %}
{{ t.row_from(item) }} {# Automatically extracts item.id, item.title, item.status #}
{% endfor %}
For nested fields, use key:
{% set t = tabular([
{"name": "Author", "key": "author.name", "width": 20},
{"name": "Email", "key": "author.email", "width": 30}
]) %}
Column Styles
Apply styles to entire columns:
{% set t = tabular([
{"name": "id", "width": 8, "style": "muted"},
{"name": "name", "width": 20, "style": "bold"},
{"name": "status", "width": 10} {# No automatic style #}
]) %}
The style value wraps content in style tags: [muted]abc123[/muted]
For dynamic styles (style based on value):
{% for item in items %}
{{ t.row([item.id, item.name, item.status | style_as(item.status)]) }}
{% endfor %}
This applies [pending]pending[/pending] or [done]done[/done] based on the actual status value.
Defining Layout in Rust
For reusable layouts or when you need full control:
#![allow(unused)] fn main() { use standout::tabular::{TabularSpec, Col}; let spec = TabularSpec::builder() .column(Col::fixed(8).named("id")) .column(Col::min(10).named("name").style("author")) .column(Col::fill().named("description").wrap()) .column(Col::fixed(10).named("status").anchor_right().right()) .separator(" ") .build(); }
Pass to template context:
#![allow(unused)] fn main() { let formatter = TabularFormatter::new(&spec, 80); ctx.insert("table", formatter); }
Shorthand Column Constructors
#![allow(unused)] fn main() { Col::fixed(8) // Exactly 8 columns Col::min(10) // At least 10, grows to fit Col::bounded(10, 30) // Between 10 and 30 Col::fill() // Takes remaining space Col::fraction(2) // 2 parts of remaining (2fr) // Chained modifiers Col::fixed(10) .named("status") // Column name .right() // Align right .center() // Align center .anchor_right() // Position at right edge .wrap() // Overflow: wrap .clip() // Overflow: hard cut .truncate_middle() // Truncate in middle .style("pending") // Apply style .null_repr("N/A") // Display for missing values }
Tables: Headers and Borders
For output with explicit headers, separators, and borders:
{% set t = table([
{"name": "ID", "key": "id", "width": 8},
{"name": "Author", "key": "author", "width": 20},
{"name": "Message", "key": "message", "width": "fill"}
], border="rounded", header_style="bold") %}
{{ t.header_row() }}
{{ t.separator_row() }}
{% for commit in commits %}
{{ t.row([commit.id, commit.author, commit.message]) }}
{% endfor %}
{{ t.bottom_border() }}
Output:
╭──────────┬──────────────────────┬────────────────────────────────╮
│ ID │ Author │ Message │
├──────────┼──────────────────────┼────────────────────────────────┤
│ a1b2c3d4 │ Alice Johnson │ Add new login feature │
│ e5f6g7h8 │ Bob Smith │ Fix authentication bug │
│ i9j0k1l2 │ Carol Williams │ Update dependencies │
╰──────────┴──────────────────────┴────────────────────────────────╯
Table Border Styles
border="none" {# No borders #}
border="ascii" {# +--+--+ ASCII compatible #}
border="light" {# ┌──┬──┐ Light box drawing #}
border="heavy" {# ┏━━┳━━┓ Heavy box drawing #}
border="double" {# ╔══╦══╗ Double lines #}
border="rounded" {# ╭──┬──╮ Rounded corners #}
Row Separators
Add lines between data rows:
{% set t = table(columns, border="light", row_separator=true) %}
┌──────────┬──────────────────────┐
│ ID │ Name │
├──────────┼──────────────────────┤
│ abc123 │ Alice │
├──────────┼──────────────────────┤
│ def456 │ Bob │
└──────────┴──────────────────────┘
Simple Table Rendering
For simple cases, render everything in one call using render_all():
{% set t = table([
{"width": 8, "header": "ID"},
{"width": 20, "header": "Author"}
], border="light", header=["ID", "Author"]) %}
{{ t.render_all(commits) }}
In Rust: Full Table API
#![allow(unused)] fn main() { use standout::tabular::{Table, TabularSpec, Col, BorderStyle}; let spec = TabularSpec::builder() .column(Col::fixed(8).header("ID")) .column(Col::min(10).header("Author")) .column(Col::fill().named("Message")) .build(); let table = Table::new(spec, 80) .header_from_columns() // Use column headers/names as headers .header_style("table-header") .border(BorderStyle::Rounded); // Render full table let data = vec![ vec!["a1b2c3d4", "Alice", "Add login"], vec!["e5f6g7h8", "Bob", "Fix bug"], ]; let output = table.render(&data); println!("{}", output); // Or render parts manually println!("{}", table.header_row()); println!("{}", table.separator_row()); for row in &data { println!("{}", table.row(row)); } println!("{}", table.bottom_border()); }
Terminal Width
By default, Standout auto-detects terminal width. Override for testing or fixed-width output:
{% set t = tabular(columns, width=80) %} {# Fixed 80 columns #}
#![allow(unused)] fn main() { let formatter = TabularFormatter::new(&spec, 80); // Fixed width }
Helper Filters
For simpler use cases, these filters work standalone:
Padding
{{ value | pad_right(10) }} {# "hello " - left align #}
{{ value | pad_left(10) }} {# " hello" - right align #}
{{ value | pad_center(10) }} {# " hello " - center #}
Truncation
{{ path | truncate_at(20) }} {# End: "/very/long/path…" #}
{{ path | truncate_at(20, "middle") }} {# Middle: "/very…/file" #}
{{ path | truncate_at(20, "start") }} {# Start: "…long/path/file" #}
{{ path | truncate_at(20, "end", "...") }} {# Custom marker #}
Display Width
Check visual width (handles Unicode):
{% if name | display_width > 20 %}
{{ name | truncate_at(20) }}
{% else %}
{{ name }}
{% endif %}
Unicode and ANSI
The tabular system correctly handles:
- CJK characters: 日本語 counts as 6 columns (2 each)
- Combining marks: café is 4 columns (é combines)
- ANSI codes: Preserved in output, not counted in width
{{ "Hello 日本" | col(12) }} → "Hello 日本 " (10 display columns + 2 padding)
Styled text maintains styles through truncation:
{{ "[red]very long red text[/red]" | col(10) }} → "[red]very lon…[/red]"
Missing Values
Set what displays for null/empty values:
{% set t = tabular([
{"name": "email", "width": 30, "null_repr": "N/A"}
]) %}
Or in templates with Jinja's default filter:
{{ entry.email | default("N/A") | col(30) }}
Complete Example
A git log-style output:
{% set t = tabular([
{"name": "hash", "width": 8, "style": "muted"},
{"name": "author", "width": {"min": 15, "max": 25}, "style": "author"},
{"name": "message", "width": "fill"},
{"name": "date", "width": 10, "anchor": "right", "align": "right", "style": "date"}
], separator=" │ ") %}
{% for commit in commits %}
{{ t.row([commit.hash[:8], commit.author, commit.message, commit.date]) }}
{% endfor %}
Output (80 columns):
a1b2c3d4 │ Alice Johnson │ Add new login feature with OAuth │ 2024-01-15
e5f6g7h8 │ Bob Smith │ Fix authentication bug │ 2024-01-14
i9j0k1l2 │ Carol Williams │ Update dependencies and refactor │ 2024-01-13
With styling (in terminal):
[muted]a1b2c3d4[/muted] │ [author]Alice Johnson[/author] │ Add new login feature with OAuth │ [date]2024-01-15[/date]
Summary
| Need | Solution |
|---|---|
| Simple column alignment | {{ value | col(width) }} |
| Multiple columns, same widths | tabular([...]) with t.row([...]) |
| Auto field extraction | t.row_from(object) |
| Headers and borders | table([...]) |
| Right-edge columns | anchor: "right" |
| Long content wrapping | overflow: "wrap" |
| Proportional widths | width: "2fr" |
Reference
col Filter
{{ value | col(width, align=?, truncate=?, ellipsis=?) }}
| Param | Values | Default |
|---|---|---|
width | integer | required |
align | "left", "right", "center" | "left" |
truncate | "end", "start", "middle" | "end" |
ellipsis | string | "…" |
Column Spec
{
"name": "string",
"key": "field.path",
"width": 8 | {"min": 5} | {"min": 5, "max": 20} | "fill" | "2fr",
"align": "left" | "right" | "center",
"anchor": "left" | "right",
"overflow": "truncate" | "wrap" | "clip" | {"truncate": {...}} | {"wrap": {...}},
"style": "style-name",
"null_repr": "-"
}
tabular() Function
{% set t = tabular(columns, separator=?, width=?) %}
{{ t.row([values]) }}
{{ t.row_from(object) }}
table() Function
{% set t = table(columns, border=?, header=?, header_style=?, row_separator=?, width=?) %}
{{ t.header_row() }}
{{ t.separator_row() }}
{{ t.row([values]) }}
{{ t.row_from(object) }}
{{ t.top_border() }}
{{ t.bottom_border() }}
{{ t.render_all(rows) }}
Border Styles
| Value | Example |
|---|---|
"none" | No borders |
"ascii" | +--+--+ |
"light" | ┌──┬──┐ |
"heavy" | ┏━━┳━━┓ |
"double" | ╔══╦══╗ |
"rounded" | ╭──┬──╮ |
App Configuration
AppBuilder is the unified entry point for configuring your application. Instead of scattering configuration across multiple structs (Standout, RenderSetup, Theme), everything from command registration to theme selection happens in one fluent interface.
This design ensures that your application defines its entire environment—commands, styles, templates, and hooks—before the runtime starts, preventing configuration race conditions and simplifying testing.
This guide covers the full setup: embedding resources, registering commands, configuring themes, and customizing behavior.
See also:
- Rendering System for details on templates and styles.
- Topics System for help topics.
Basic Setup
#![allow(unused)] fn main() { use standout::cli::App; use standout_macros::{embed_templates, embed_styles}; let app = App::builder() .templates(embed_templates!("src/templates")) .styles(embed_styles!("src/styles")) .default_theme("default") .command("list", list_handler, "list.j2") .build()?; app.run(Cli::command(), std::env::args()); }
Embedding Resources
Templates
embed_templates! embeds template files at compile time:
#![allow(unused)] fn main() { .templates(embed_templates!("src/templates")) }
Collects files matching: .jinja, .jinja2, .j2, .txt (in priority order).
Directory structure:
src/templates/
list.j2
add.j2
db/
migrate.j2
status.j2
Templates are referenced by path without extension: "list", "db/migrate".
Styles
embed_styles! embeds stylesheet files:
#![allow(unused)] fn main() { .styles(embed_styles!("src/styles")) }
Collects files matching: .yaml, .yml.
src/styles/
default.yaml
dark.yaml
light.yaml
Themes are referenced by filename without extension: "default", "dark".
Hot Reloading
In debug builds, embedded resources are re-read from disk on each render—edit without recompiling. In release builds, embedded content is used directly.
This is automatic when the source path exists on disk.
Runtime Overrides
Users can override embedded resources with local files:
#![allow(unused)] fn main() { App::builder() .templates(embed_templates!("src/templates")) .templates_dir("~/.myapp/templates") // Overrides embedded .styles(embed_styles!("src/styles")) .styles_dir("~/.myapp/themes") // Overrides embedded }
Local directories take precedence. This enables user customization without recompiling.
Theme Selection
From Stylesheet Registry
#![allow(unused)] fn main() { .styles(embed_styles!("src/styles")) // Optional: set explicit default name // If omitted, tries "default", "theme", then "base" .default_theme("dark") }
If .default_theme() is not called, AppBuilder attempts to load a theme from the registry in this order:
defaultthemebase
This allows you to provide a standard base.yaml or theme.yaml without requiring explicit configuration code. If the explicit theme isn't found, build() returns SetupError::ThemeNotFound.
Explicit Theme
#![allow(unused)] fn main() { let theme = Theme::new() .add("title", Style::new().bold().cyan()) .add("muted", Style::new().dim()); App::builder() .theme(theme) // Overrides stylesheet registry }
Explicit .theme() takes precedence over .default_theme().
Command Registration
Simple Commands
#![allow(unused)] fn main() { App::builder() .command("list", list_handler, "list.j2") .command("add", add_handler, "add.j2") }
Arguments: command name, handler function, template path.
With Configuration
#![allow(unused)] fn main() { App::builder() .command_with("delete", delete_handler, |cfg| cfg .template("delete.j2") .pre_dispatch(require_confirmation) .post_dispatch(log_deletion)) }
Inline hook attachment without separate .hooks() call.
Nested Groups
#![allow(unused)] fn main() { App::builder() .group("db", |g| g .command("migrate", migrate_handler, "db/migrate.j2") .command("status", status_handler, "db/status.j2") .group("backup", |b| b .command("create", backup_create, "db/backup/create.j2") .command("restore", backup_restore, "db/backup/restore.j2"))) }
Creates command paths: db.migrate, db.status, db.backup.create, db.backup.restore.
From Dispatch Macro
#![allow(unused)] fn main() { #[derive(Dispatch)] enum Commands { List, Add, #[dispatch(nested)] Db(DbCommands), } App::builder() .commands(Commands::dispatch_config()) }
The macro generates registration for all variants.
Default Command
When a CLI is invoked without a subcommand (a "naked" invocation like myapp or myapp --verbose), you can specify a default command to run:
#![allow(unused)] fn main() { App::builder() .default_command("list") .command("list", list_handler, "list.j2") .command("add", add_handler, "add.j2") }
With this configuration:
myappbecomesmyapp listmyapp --output=jsonbecomesmyapp list --output=jsonmyapp add foostays asmyapp add foo(explicit command takes precedence)
With Dispatch Macro
Use the #[dispatch(default)] attribute to mark a variant as the default:
#![allow(unused)] fn main() { #[derive(Dispatch)] #[dispatch(handlers = handlers)] enum Commands { #[dispatch(default)] List, Add, } App::builder() .commands(Commands::dispatch_config()) }
Only one command can be marked as default. Multiple #[dispatch(default)] attributes will cause a compile error.
Hooks
Attach hooks to specific command paths:
#![allow(unused)] fn main() { App::builder() .command("migrate", migrate_handler, "migrate.j2") .hooks("db.migrate", Hooks::new() .pre_dispatch(require_admin) .post_dispatch(add_timestamp) .post_output(log_result)) }
The path uses dot notation matching the command hierarchy.
Context Injection
Add values available in all templates:
Static Context
#![allow(unused)] fn main() { App::builder() .context("version", "1.0.0") .context("app_name", "MyApp") }
Dynamic Context
#![allow(unused)] fn main() { App::builder() .context_fn("terminal_width", |ctx| { Value::from(ctx.terminal_width.unwrap_or(80)) }) .context_fn("timestamp", |_ctx| { Value::from(chrono::Utc::now().to_rfc3339()) }) }
Dynamic providers receive RenderContext with output mode, terminal width, and handler data.
Topics
Add help topics:
#![allow(unused)] fn main() { App::builder() .topics_dir("docs/topics") .add_topic(Topic::new("auth", "Authentication...", TopicType::Text, None)) }
See Topics System for details.
Flag Customization
Output Flag
#![allow(unused)] fn main() { App::builder() .output_flag(Some("format")) // --format instead of --output }
#![allow(unused)] fn main() { App::builder() .no_output_flag() // Disable entirely }
File Output Flag
#![allow(unused)] fn main() { App::builder() .output_file_flag(Some("out")) // --out instead of --output-file-path }
#![allow(unused)] fn main() { App::builder() .no_output_file_flag() // Disable entirely }
The App Struct
build() produces an App:
#![allow(unused)] fn main() { pub struct App { registry: TopicRegistry, output_flag: Option<String>, output_file_flag: Option<String>, output_mode: OutputMode, theme: Option<Theme>, command_hooks: HashMap<String, Hooks>, template_registry: Option<TemplateRegistry>, stylesheet_registry: Option<StylesheetRegistry>, } }
Running the App
Standard Execution
#![allow(unused)] fn main() { if let Some(matches) = app.run(Cli::command(), std::env::args()) { // Standout didn't handle this command, fall back to legacy legacy_dispatch(matches); } }
Parses args, dispatches to handler, prints output. Returns Option<ArgMatches>—None if handled, Some(matches) for fallback.
Capture Output
For testing, post-processing, or when you need the output string:
#![allow(unused)] fn main() { match app.run_to_string(cmd, args) { RunResult::Handled(output) => { /* use output string */ } RunResult::Binary(bytes, filename) => { /* handle binary */ } RunResult::NoMatch(matches) => { /* fallback dispatch */ } } }
Returns RunResult instead of printing.
Parse Only
#![allow(unused)] fn main() { let matches = app.parse_with(cmd); // Use matches for manual dispatch }
Parses with Standout's augmented command but doesn't dispatch.
Build Validation
build() validates:
- Theme exists if
.default_theme()was called - Returns
SetupError::ThemeNotFoundif not found
What's NOT validated at build time:
- Templates (resolved lazily at render time)
- Command handlers
- Hook signatures (verified at registration)
Complete Example
use standout::cli::{App, HandlerResult, Output}; use standout_macros::{embed_templates, embed_styles}; use clap::{Command, Arg}; use serde::Serialize; #[derive(Serialize)] struct ListOutput { items: Vec<String>, } fn list_handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<ListOutput> { let items = vec!["one".into(), "two".into()]; Ok(Output::Render(ListOutput { items })) } fn main() -> Result<(), Box<dyn std::error::Error>> { let cli = Command::new("myapp") .subcommand(Command::new("list").about("List items")); let app = App::builder() .templates(embed_templates!("src/templates")) .styles(embed_styles!("src/styles")) .default_theme("default") .context("version", env!("CARGO_PKG_VERSION")) .command("list", list_handler, "list.j2") .topics_dir("docs/topics") .build()?; app.run(cli, std::env::args()); Ok(()) }
Template src/templates/list.j2:
[header]Items[/header] ({{ items | length }} total)
{% for item in items %}
- {{ item }}
{% endfor %}
[muted]v{{ version }}[/muted]
Style src/styles/default.yaml:
header:
fg: cyan
bold: true
muted:
dim: true
Execution Model
Standout manages a strict linear pipeline from CLI input to rendered output. This explicitly separated flow ensures that logic (Handlers) remains decoupled from presentation (Templates) and side-effects (Hooks).
Understanding this model allows you to extend the framework predictably—knowing exactly where to intercept execution, what data is available, and how to test each stage in isolation.
The Pipeline
Clap Parsing → Dispatch → Handler → Hooks → Rendering → Output
Each stage has a clear responsibility:
Clap Parsing: Your clap::Command definition is augmented with Standout's flags (--output, custom help) and parsed normally. Standout doesn't replace clap—it builds on top of it.
Dispatch: Standout extracts the command path from the parsed ArgMatches, navigating through subcommands to find the deepest match. It then looks up the registered handler for that path.
Handler: Your logic function executes. It receives the ArgMatches and a CommandContext, returning a HandlerResult<T>—either data to render, a silent marker, or binary content. With App, handlers use &self; with LocalApp, handlers use &mut self (see Handler Contract).
Hooks: If registered, hooks run at three points around the handler. They can validate, transform, or intercept without modifying handler logic.
Rendering: Handler output is serialized and passed through the template engine, producing styled terminal output (or structured data like JSON, depending on output mode).
Output: The result is written to stdout or a file.
This pipeline is what Standout manages for you—the glue code between "I have a clap definition" and "I want rich, testable output."
- See Handler Contract for handler details.
- See Rendering System for the render phase.
Command Paths
A command path is a vector of strings representing the subcommand chain:
myapp db migrate --steps 5
The command path is ["db", "migrate"].
When you register commands with AppBuilder:
#![allow(unused)] fn main() { App::builder() .command("list", list_handler, "list.j2") .group("db", |g| g .command("migrate", migrate_handler, "db/migrate.j2") .command("status", status_handler, "db/status.j2")) .build()? }
Standout builds an internal registry mapping paths to handlers:
["list"]→list_handler["db", "migrate"]→migrate_handler["db", "status"]→status_handler
The command path is available in CommandContext.command_path for your handler to inspect, and uses dot notation ("db.migrate") when registering hooks.
The Hooks System
Hooks are functions that run at specific points in the pipeline. They let you intercept, validate, or transform without touching handler logic—keeping concerns separated.
Three Phases
Pre-dispatch: Runs before the handler. Can abort execution.
Use for: authentication checks, input validation, logging start time. The hook receives ArgMatches and CommandContext but returns no data—only success or failure.
#![allow(unused)] fn main() { Hooks::new().pre_dispatch(|matches, ctx| { if !is_authenticated() { return Err(HookError::pre_dispatch("authentication required")); } Ok(()) }) }
Post-dispatch: Runs after the handler, before rendering. Can transform data.
Use for: adding timestamps, filtering sensitive fields, data enrichment. The hook receives handler output as serde_json::Value, allowing generic transformations regardless of the handler's output type.
#![allow(unused)] fn main() { Hooks::new().post_dispatch(|_matches, _ctx, mut data| { if let Some(obj) = data.as_object_mut() { obj.insert("generated_at".into(), json!(Utc::now().to_rfc3339())); } Ok(data) }) }
Post-output: Runs after rendering. Can transform the final string.
Use for: adding headers/footers, compression, logging. The hook receives RenderedOutput—an enum of Text(String), Binary(Vec<u8>, String), or Silent.
#![allow(unused)] fn main() { Hooks::new().post_output(|_matches, _ctx, output| { match output { RenderedOutput::Text(s) => { Ok(RenderedOutput::Text(format!("{}\n-- Generated by MyApp", s))) } other => Ok(other), } }) }
Hook Chaining
Multiple hooks per phase are supported. Pre-dispatch hooks run sequentially—first error aborts. Post-dispatch and post-output hooks chain: each receives the output of the previous, enabling composable transformations.
#![allow(unused)] fn main() { Hooks::new() .post_dispatch(add_metadata) // Runs first .post_dispatch(filter_sensitive) // Receives add_metadata's output }
Order matters: filter_sensitive sees the metadata that add_metadata inserted.
Attaching Hooks
Two approaches:
#![allow(unused)] fn main() { // Via AppBuilder.hooks() with dot-notation path App::builder() .command("migrate", migrate_handler, "migrate.j2") .hooks("db.migrate", Hooks::new() .pre_dispatch(require_admin)) .build()? // Via command_with() inline App::builder() .command_with("migrate", migrate_handler, |cfg| cfg .template("migrate.j2") .pre_dispatch(require_admin)) .build()? }
See App Configuration for more on registration.
Error Handling
When a hook returns Err(HookError):
- Execution stops immediately
- Remaining hooks in that phase don't run
- For pre-dispatch: the handler never executes
- For post phases: the rendered output is discarded
- The error message becomes the command output
HookError includes the phase and an optional source error for context:
#![allow(unused)] fn main() { HookError::pre_dispatch("database connection failed") .with_source(db_error) }
Dispatch Details
When app.run() executes:
- Clap parses the arguments
- Standout traverses the
ArgMatchessubcommand chain to find the deepest match - If no subcommand was specified and a default command is configured, Standout inserts the default command and reparses
- It extracts the command path (e.g.,
["db", "migrate"]) - It looks up the handler for that path
- It executes the handler with the appropriate
ArgMatchesslice
Default Command Behavior
When you configure a default command:
#![allow(unused)] fn main() { App::builder() .default_command("list") }
A "naked" invocation like myapp or myapp --verbose will automatically dispatch to the list command. The arguments are modified internally to insert the command name, then reparsed. This ensures all clap validation and parsing rules apply correctly to the default command.
If no handler matches, run() returns Some(matches), letting you fall back to manual dispatch:
#![allow(unused)] fn main() { if let Some(matches) = app.run(cmd, args) { // Standout didn't handle this command, fall back to legacy match matches.subcommand() { Some(("legacy", sub)) => legacy_handler(sub), _ => {} } } }
This enables gradual adoption—Standout handles some commands while others use your existing code.
What Standout Adds to Your Command
When you call app.run(), Standout augments your clap::Command with:
Custom help subcommand:
myapp help # Main help
myapp help topic-name # Specific topic
myapp help --page # Use pager for long content
Global --output flag:
myapp list --output=json
myapp db status --output=yaml
Values: auto, term, text, term-debug, json, yaml, xml, csv
Global --output-file-path flag:
myapp list --output-file-path=results.txt
These flags are global—they apply to all subcommands. You can rename or disable them via AppBuilder:
#![allow(unused)] fn main() { App::builder() .output_flag(Some("format")) // Rename to --format .no_output_file_flag() // Disable file output }
App vs LocalApp Dispatch
Both App and LocalApp follow the same pipeline, with one key difference:
| Aspect | App | LocalApp |
|---|---|---|
| Handler storage | Arc<dyn Fn + Send + Sync> | Rc<RefCell<dyn FnMut>> |
| Handler call | handler.handle(...) with &self | handler.handle(...) with &mut self |
| Run method | app.run(cmd, args) with &self | app.run(cmd, args) with &mut self |
| Thread safety | Yes | No |
Choose LocalApp when your handlers need mutable access to captured state without interior mutability wrappers. See Handler Contract for detailed guidance.
How To: Adopt Standout Alongside Existing Clap Code
Adopting a new framework shouldn't require rewriting your entire application. Standout is designed for gradual adoption, allowing you to migrate one command at a time without breaking existing functionality.
This guide shows how to run Standout alongside your existing manual dispatch or Clap loop.
The Core Pattern
When Standout handles a command, it prints output and returns None. When no handler matches, it returns Some(ArgMatches) for your fallback:
#![allow(unused)] fn main() { if let Some(matches) = app.run(cli, std::env::args()) { // Standout didn't handle this command, fall back to legacy your_existing_dispatch(matches); } }
Pattern 1: Standout First, Fallback Second
Try Standout dispatch first. If no match, use your existing code:
use standout::cli::App; use clap::Command; fn main() { let cli = Command::new("myapp") .subcommand(Command::new("list")) // Standout handles .subcommand(Command::new("status")) // Your existing code .subcommand(Command::new("config")); // Your existing code // Build Standout for just the commands you want let app = App::builder() .command("list", list_handler, "list.j2") .build() .expect("Failed to build app"); // Try Standout first if let Some(matches) = app.run(cli, std::env::args()) { // Fall back to your existing dispatch match matches.subcommand() { Some(("status", sub)) => handle_status(sub), Some(("config", sub)) => handle_config(sub), _ => eprintln!("Unknown command"), } } }
Pattern 2: Existing Code First, Standout Fallback
Your dispatch handles known commands, Standout handles new ones:
fn main() { let cli = build_cli(); let matches = cli.clone().get_matches(); // Your existing dispatch first match matches.subcommand() { Some(("legacy-cmd", sub)) => { handle_legacy(sub); return; } Some(("old-feature", sub)) => { handle_old_feature(sub); return; } _ => { // Not handled by existing code } } // Standout handles everything else let app = build_standout_app(); app.run(cli, std::env::args()); }
Pattern 3: Standout Inside Your Match
Call Standout for specific commands within your existing match:
fn main() { let cli = build_cli(); let matches = cli.clone().get_matches(); match matches.subcommand() { Some(("status", sub)) => handle_status(sub), Some(("config", sub)) => handle_config(sub), // Use Standout just for these Some(("list", _)) | Some(("show", _)) => { let app = build_standout_app(); app.run(cli, std::env::args()); } _ => eprintln!("Unknown command"), } }
Adding Standout to One Command
Minimal setup for a single command:
#![allow(unused)] fn main() { let app = App::builder() .command("list", |matches, ctx| { let items = fetch_items()?; Ok(Output::Render(ListOutput { items })) }, "{% for item in items %}- {{ item }}\n{% endfor %}") .build()?; }
No embedded files required. The template is inline. No theme means style tags show ? markers, but rendering still works.
Sharing Clap Command Definition
Standout augments your clap::Command with --output and help. You can share the definition:
fn build_cli() -> Command { Command::new("myapp") .subcommand(Command::new("list").about("List items")) .subcommand(Command::new("status").about("Show status")) } fn main() { let cli = build_cli(); let app = App::builder() .command("list", list_handler, "list.j2") .build()?; // Standout augments the command, then dispatches if let Some(matches) = app.run(cli, std::env::args()) { // matches from the augmented command (has --output, etc.) match matches.subcommand() { Some(("status", sub)) => handle_status(sub), _ => {} } } }
Gradual Migration Strategy
-
Start with one command: Pick a command with complex output. Add Standout for just that command.
-
Keep existing tests passing: Your dispatch logic stays the same for unhandled commands.
-
Add more commands over time: Register additional handlers as you refactor.
-
Add themes when ready: Start with inline templates, add YAML stylesheets later.
-
Eventually remove legacy dispatch: Once all commands are migrated, simplify to just
app.run().
Using run() vs run_to_string()
run() prints output directly and returns Option<ArgMatches>:
#![allow(unused)] fn main() { if let Some(matches) = app.run(cli, args) { // Standout didn't handle, use matches for fallback legacy_dispatch(matches); } }
run_to_string() captures output instead of printing, returning RunResult:
#![allow(unused)] fn main() { match app.run_to_string(cli, args) { RunResult::Handled(output) => { /* process output string */ } RunResult::Binary(bytes, filename) => { /* handle binary */ } RunResult::NoMatch(matches) => { /* access matches */ } } }
Use run_to_string() when you need to:
- Capture output for testing
- Post-process the output string before printing
- Log or record what was generated
For normal partial adoption, run() is simpler and preferred.
Accessing the --output Flag in Fallback
Standout adds --output globally. In fallback code, you can still access it:
#![allow(unused)] fn main() { if let Some(matches) = app.run(cli, std::env::args()) { // Get the output mode Standout parsed let mode = matches.get_one::<String>("_output_mode") .map(|s| s.as_str()) .unwrap_or("auto"); match matches.subcommand() { Some(("status", sub)) => { if mode == "json" { println!("{}", serde_json::to_string(&status_data)?); } else { print_status_text(&status_data); } } _ => {} } } }
Disabling Standout's Flags
If --output conflicts with your existing flags:
#![allow(unused)] fn main() { App::builder() .no_output_flag() // Don't add --output .no_output_file_flag() // Don't add --output-file-path .command("list", handler, template) .build()? }
Example: Hybrid Application
Complete example with both Standout and manual handlers:
use standout::cli::{App, HandlerResult, Output, CommandContext}; use clap::{Command, ArgMatches}; use serde::Serialize; #[derive(Serialize)] struct ListOutput { items: Vec<String> } fn list_handler(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<ListOutput> { Ok(Output::Render(ListOutput { items: vec!["one".into(), "two".into()], })) } fn handle_status(_matches: &ArgMatches) { println!("Status: OK"); } fn main() -> Result<(), Box<dyn std::error::Error>> { let cli = Command::new("myapp") .subcommand(Command::new("list").about("List items (Standout)")) .subcommand(Command::new("status").about("Show status (legacy)")); let app = App::builder() .command("list", list_handler, "{% for i in items %}- {{ i }}\n{% endfor %}") .build()?; if let Some(matches) = app.run(cli, std::env::args()) { match matches.subcommand() { Some(("status", sub)) => handle_status(sub), _ => eprintln!("Unknown command"), } } Ok(()) }
Run it:
myapp list # Standout handles, renders template
myapp list --output=json # Standout handles, JSON output
myapp status # Fallback to handle_status()
How To: Use Only the Rendering Layer
Standout's rendering layer is fully decoupled from its CLI integration (App, Clap, Dispatch). This means you can use the template engine, theme system, and structured output logic in any context—servers, TUI apps, or even other CLI frameworks.
This decoupling allows you to maintain consistent styling and logic across different parts of your ecosystem.
When to Use This
- Adding styled output to an existing application
- Building a library that produces formatted terminal output
- Server-side rendering of CLI-style output
- Testing templates in isolation
Basic Rendering
The simplest approach—auto-detect terminal capabilities:
use standout::{render, Theme}; use console::Style; use serde::Serialize; #[derive(Serialize)] struct Report { title: String, items: Vec<String>, } fn main() -> Result<(), Box<dyn std::error::Error>> { let theme = Theme::new() .add("title", Style::new().bold().cyan()) .add("item", Style::new().green()); let data = Report { title: "Status Report".into(), items: vec!["Task A: complete".into(), "Task B: pending".into()], }; let output = render( r#"[title]{{ title }}[/title] {% for item in items %} [item]•[/item] {{ item }} {% endfor %}"#, &data, &theme, )?; println!("{}", output); Ok(()) }
Explicit Output Mode
Control ANSI code generation:
#![allow(unused)] fn main() { use standout::{render_with_output, OutputMode}; // Force ANSI codes (even when piping) let colored = render_with_output(template, &data, &theme, OutputMode::Term)?; // Force plain text (no ANSI codes) let plain = render_with_output(template, &data, &theme, OutputMode::Text)?; // Debug mode (tags as literals) let debug = render_with_output(template, &data, &theme, OutputMode::TermDebug)?; }
Auto-Dispatch: Template vs Serialization
render_auto chooses between template rendering and direct serialization:
#![allow(unused)] fn main() { use standout::render_auto; fn format_output(data: &Report, mode: OutputMode) -> Result<String, Error> { render_auto(template, data, &theme, mode) } // Term/Text/Auto: renders template format_output(&data, OutputMode::Term)?; // Json/Yaml/Xml/Csv: serializes data directly format_output(&data, OutputMode::Json)?; }
Same function, same data—output format determined by mode.
Full Control: Output Mode + Color Mode
For tests or when forcing specific behavior:
#![allow(unused)] fn main() { use standout::{render_with_mode, ColorMode}; // Force dark mode styling let dark = render_with_mode( template, &data, &theme, OutputMode::Term, ColorMode::Dark, )?; // Force light mode styling let light = render_with_mode( template, &data, &theme, OutputMode::Term, ColorMode::Light, )?; }
Building Themes Programmatically
No YAML files needed:
#![allow(unused)] fn main() { use standout::Theme; use console::{Style, Color}; let theme = Theme::new() // Simple styles .add("bold", Style::new().bold()) .add("muted", Style::new().dim()) .add("error", Style::new().red().bold()) // With specific colors .add("info", Style::new().fg(Color::Cyan)) .add("warning", Style::new().fg(Color::Yellow)) // Aliases .add("disabled", "muted") .add("inactive", "muted") .add_adaptive( "panel", Style::new().bold(), Some(Style::new().fg(Color::Black)), // Light mode Some(Style::new().fg(Color::White)), // Dark mode ); }
Theme Merging
You can layer themes using merge. This is useful for user overrides:
#![allow(unused)] fn main() { let base_theme = Theme::from_file("base.yaml")?; let user_overrides = Theme::from_file("user-config.yaml")?; // User styles overwrite base styles let final_theme = base_theme.merge(user_overrides); }
Pre-Compiled Renderer
For repeated rendering with the same templates:
#![allow(unused)] fn main() { use standout::Renderer; let theme = Theme::new() .add("title", Style::new().bold()); let mut renderer = Renderer::new(theme)?; // Register templates renderer.add_template("header", "[title]{{ title }}[/title]")?; renderer.add_template("item", " - {{ name }}: {{ value }}")?; // Render multiple times for record in records { let header = renderer.render("header", &record)?; println!("{}", header); for item in &record.items { let line = renderer.render("item", item)?; println!("{}", line); } } }
Loading Templates from Files
#![allow(unused)] fn main() { let mut renderer = Renderer::new(theme)?; // Add directory of templates renderer.add_template_dir("./templates")?; // Templates resolved by name (without extension) let output = renderer.render("report", &data)?; }
In debug builds, file-based templates are re-read on each render (hot reload).
Using Embedded Templates
For release builds, embed templates at compile time:
#![allow(unused)] fn main() { use standout::{embed_templates, Renderer, Theme}; let theme = Theme::new() .add("title", Style::new().bold()); let mut renderer = Renderer::new(theme)?; // Load all templates from the embedded source renderer.with_embedded_source(embed_templates!("src/templates")); // Render by name (with or without extension) let output = renderer.render("report", &data)?; // Includes work with extensionless names // If src/templates/_header.jinja exists, use {% include "_header" %} }
Templates are accessible by both extensionless name ("report") and with extension ("report.jinja").
Loading Themes from Embedded Styles
For production deployments, embed stylesheets:
#![allow(unused)] fn main() { use standout::{embed_styles, StylesheetRegistry, Renderer}; // Embed all .yaml files from src/styles/ let styles = embed_styles!("src/styles"); // Convert to a registry for theme lookup let mut registry: StylesheetRegistry = styles.into(); // Get a theme by name (e.g., "default" for src/styles/default.yaml) let theme = registry.get("default")?; // Use with Renderer let mut renderer = Renderer::new(theme)?; }
The relationship:
embed_styles!→EmbeddedStyles(compile-time embedding)StylesheetRegistry→ manages multiple themes, hot-reload in debugTheme→ resolved styles for a single theme, used by Renderer
Feature Support: Includes
Template includes ({% include "partial" %}) require a template registry:
| Approach | Includes | Notes |
|---|---|---|
Renderer | ✓ | Use add_template() or with_embedded_source() |
render() / render_auto() | ✗ | Takes template string, no registry |
For one-off templates without includes, use the standalone render* functions.
For multi-template projects with includes, use Renderer.
Template Validation
Catch style tag errors without producing output:
#![allow(unused)] fn main() { use standout::validate_template; let result = validate_template(template, &sample_data, &theme); match result { Ok(()) => println!("Template is valid"), Err(e) => { eprintln!("Template errors: {}", e); std::process::exit(1); } } }
Use at startup or in tests to fail fast on typos.
Context Injection
Simple Variables with render_with_vars
For adding simple key-value pairs to the template context:
#![allow(unused)] fn main() { use standout::{render_with_vars, Theme, OutputMode}; use std::collections::HashMap; let theme = Theme::new(); let mut vars = HashMap::new(); vars.insert("version", "1.0.0"); vars.insert("app_name", "MyApp"); let output = render_with_vars( "{{ name }} - {{ app_name }} v{{ version }}", &data, &theme, OutputMode::Text, vars, )?; }
This is the recommended approach for most use cases.
Full Context System
For dynamic context computed at render time:
#![allow(unused)] fn main() { use standout::{render_with_context, Theme, OutputMode}; use standout::context::{ContextRegistry, RenderContext}; use minijinja::Value; let mut context = ContextRegistry::new(); context.add_static("version", Value::from("1.0.0")); context.add_provider("timestamp", |_ctx: &RenderContext| { Value::from(chrono::Utc::now().to_rfc3339()) }); let render_ctx = RenderContext::new( OutputMode::Term, Some(80), &theme, &serde_json::to_value(&data)?, ); let output = render_with_context( template, &data, &theme, OutputMode::Term, &context, &render_ctx, )?; }
Structured Output Without Templates
For JSON/YAML output, templates are bypassed:
#![allow(unused)] fn main() { use standout::render_auto; #[derive(Serialize)] struct ApiResponse { status: String, data: Vec<Item>, } let response = ApiResponse { ... }; // Direct JSON serialization let json = render_auto("unused", &response, &theme, OutputMode::Json)?; println!("{}", json); // Direct YAML serialization let yaml = render_auto("unused", &response, &theme, OutputMode::Yaml)?; }
The template parameter is ignored for structured modes.
Minimal Example
Absolute minimum for styled output:
#![allow(unused)] fn main() { use standout::{render, Theme}; use console::Style; let theme = Theme::new().add("ok", Style::new().green()); let output = render("[ok]Success[/ok]", &(), &theme)?; println!("{}", output); }
No files, no configuration—just a theme and a template string.