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.