Skip to content

Capabilities and fidelity

Terminals differ. Some show 16 colours and some 16 million. Some lack Unicode or hyperlinks, and output is often piped to a file or a CI log. Two modules deal with this:

  • rich_ext::capabilities decides what the output can use (colour depth, Unicode, OSC 8 links, graphics, size, interactivity, animation) and records where each answer came from.
  • rich_ext::fidelity turns those answers into one level, from Animated down to Ascii, and renders any renderable at that level.

Neither module probes the terminal. Detection reads environment variables and tty facts through an Environment trait, so tests can supply a fixed one.

The examples come from guide_capabilities.rs. Detection needs no feature; the JSON form needs serde:

cargo run -p rs-rich-ext --example guide_capabilities --features serde

Detect the real terminal

fn system_report(console: &Console) {
    // Reads the real process: std::env, stdout's tty status, terminal size.
    let report: Report = Capabilities::system();
    console.print(&CapabilityReport::new(&report));
}

Capabilities::system() returns a Report. Each field is a Field<T> with the value, its origin and a human-readable reason. CapabilityReport renders the report as a table. rich doctor prints this same table.

Detect from a fixed environment

For tests, and anywhere results must not depend on the machine, use MapEnvironment: variables, whether stdout is a terminal (tty() or terminal(bool)), the terminal size and whether it is Windows.

fn wezterm() -> MapEnvironment {
    MapEnvironment::tty()
        .size(120, 40)
        .var("TERM", "xterm-256color")
        .var("TERM_PROGRAM", "WezTerm")
        .var("COLORTERM", "truecolor")
        .var("LANG", "en_US.UTF-8")
}

fn show_map(console: &Console) {
    let report = Capabilities::detect(&wezterm());
    assert_eq!(report.color.value, ColorDepth::TrueColor);
    assert_eq!(report.color.origin, Origin::Environment("COLORTERM".into()));
    assert!(report.hyperlinks.value);
    console.print(&CapabilityReport::new(&report));
}

Capabilities detected for WezTerm, with sources

Implement Environment (var, is_terminal, size, is_windows) to read facts from anywhere else.

Provenance

Origin says where a value came from:

Origin Meaning
Override An explicit Overrides value from your code
Environment(name) An environment variable, named (COLORTERM, RICH_WIDTH, …)
Inferred Derived from other facts: tty status, the terminal's identity, the platform
Default Nothing said anything; the documented default

The rules, briefly

Each field takes the first rule that matches: an Overrides value, then a RICH_* variable, then these heuristics.

  • Colour: a non-empty NO_COLOR gives none. FORCE_COLOR forces a depth. Output that is not a terminal gets none, except on CI services whose logs render ANSI (GitHub Actions gets truecolor; GitLab, Buildkite, CircleCI and others get 16). COLORTERM=truecolor, Windows Terminal, kitty, iTerm2, WezTerm, VS Code and ghostty get truecolor, and a TERM containing 256 gets 256.
  • Unicode: the locale (LC_ALL, LC_CTYPE, LANG). Yes by default.
  • Hyperlinks: only on a terminal known to support OSC 8, and not inside tmux or screen. Alacritty is left out because it does not publish a version; set RICH_HYPERLINKS=1 there.
  • Graphics and Sixel: kitty, iTerm-style inline images, or the Sixel heuristic, on a terminal only.
  • Size: RICH_WIDTH/RICH_HEIGHT, then COLUMNS/LINES, then the terminal, then 80×25.
  • Animation: an interactive terminal, not CI, not TERM=dumb, and no reduced-motion preference in RICH_A11Y.

The module docs list every rule.

Graphics and the cell size

rich_ext::graphics::GraphicsEnvironment carries what core's TargetCapabilities does not: the graphics protocol and the size of one cell in pixels, each with its origin. GraphicsEnvironment::from_report(&report, &env) reads the cell size from RICH_CELL_PIXELS=WxH, then from the terminal's window size in pixels (TIOCGWINSZ); it never writes to the terminal. GraphicsEnvironment::system() also asks the terminal with a CSI 16 t query, waiting at most 100 ms, but only when stdin and stdout are both a terminal and a graphics protocol was found. Micro assets choose their renderer from it.

RICH_* overrides

Users and CI scripts can override any answer:

Variable Values
RICH_COLOR none, 16, 256, truecolor
RICH_UNICODE 0 / 1
RICH_HYPERLINKS 0 / 1
RICH_GRAPHICS none, sixel, kitty, iterm
RICH_SIXEL 0 / 1 (kept from the CLI; 1 means sixel graphics)
RICH_ANIMATION 0 / 1
RICH_WIDTH, RICH_HEIGHT a number of cells

Booleans also accept true/false, yes/no and on/off. An invalid value is ignored and listed in Report::warnings, and the table shows it:

fn show_ci(console: &Console) {
    // Piped output on GitHub Actions, with two RICH_* overrides; one is invalid.
    let env = MapEnvironment::new()
        .var("GITHUB_ACTIONS", "true")
        .var("CI", "true")
        .var("RICH_WIDTH", "100")
        .var("RICH_HYPERLINKS", "maybe");
    let report = Capabilities::detect(&env);
    assert_eq!(report.width.value, 100);
    assert!(!report.interactive.value);
    assert_eq!(report.warnings.len(), 1); // RICH_HYPERLINKS=maybe is ignored
    console.print(&CapabilityReport::new(&report));
}

Piped output on GitHub Actions, with an ignored override

Overrides from your own flags

Overrides holds values your program decided, such as from --color or --ascii flags. detect_with(env, &overrides) applies them last, with Origin::Override. report.apply(&overrides) does the same to an existing report. rows() returns (name, value, origin, reason) tuples for your own output:

fn with_overrides() -> Report {
    // What a `--color 256 --ascii` command line might set: applied last.
    let overrides = Overrides {
        color: Some(ColorDepth::Ansi256),
        unicode: Some(false),
        width: Some(40),
        ..Overrides::default()
    };
    let report = Capabilities::detect_with(&wezterm(), &overrides);
    assert_eq!(report.color.origin, Origin::Override);
    for (name, value, origin, reason) in report.rows() {
        println!("{name:<12} {value:<10} {origin} {reason}");
    }
    report
}

With the serde feature a Report serializes. rich doctor --report json includes this as capabilities:

fn report_json(report: &Report) -> String {
    // Needs the `serde` feature.
    serde_json::to_string_pretty(report).expect("serializable")
}

Render for a report

to_target_capabilities() converts a report into core's TargetCapabilities, which a RenderTarget renders for. Nothing else is detected along the way. to_detected() gives the older DetectedCapabilities shape.

fn render_for(report: &Report, console: &Console) {
    // Detection feeds an explicit render target: nothing else is detected.
    let target = RenderTarget::new(
        TargetKind::Terminal,
        report.to_target_capabilities(),
        Theme::default_theme(),
    );
    let mut table = Table::new().title("Deploys");
    table.add_column("Service");
    table.add_column("Status");
    table.add_row_text(vec![
        Text::new("api"),
        Text::styled("✔ deployed", "bold #00d75f"),
    ]);
    // The target renders for those capabilities (256 colours, ASCII)...
    let ansi: String = target.text(&table);
    // ...and the result is ordinary ANSI text.
    for line in AnsiDecoder::new().decode(&ansi) {
        console.print(&line);
    }
}

A table rendered for a 256-colour ASCII target

The box is drawn in ASCII, but ✔ in the cell text is not replaced: core only swaps box characters. Wrap the renderable in Degrade to replace glyphs too.

Fidelity levels

Fidelity orders what output may use, lowest first:

Level Output
Ascii ASCII glyphs only, no styles
Plain Unicode, no styles
Styled Unicode with bold, italic and underline, but no colour
Rich Static full colour
Animated Full colour plus live updates and animation

Fidelity::select(&source, &policy) picks a level:

  1. no Unicode gives Ascii;
  2. no colour gives Styled on an interactive terminal (attributes still work there; NO_COLOR removes only colour) and Plain elsewhere;
  3. colour, animation allowed and interactive gives Animated;
  4. anything else gives Rich.

A Policy then applies a ceiling (never higher) and a floor (never lower; the floor wins, because the caller insists). allow_animation: false rules out Animated. The source can be a Report, core TargetCapabilities, or FidelityFacts given directly. Fidelity::for_console(&console, &policy) selects from a console.

fn show_fidelity(console: &Console) {
    let environments = [
        ("WezTerm", wezterm()),
        ("NO_COLOR", wezterm().var("NO_COLOR", "1")),
        ("piped", wezterm().terminal(false)),
        ("LANG=C.ISO-8859-1", wezterm().var("LANG", "C.ISO-8859-1")),
    ];
    let quiet = Policy::default().ceiling(Fidelity::Rich); // never animate
    for (name, env) in environments {
        let report = Capabilities::detect(&env);
        let level = Fidelity::select(&report, &Policy::default());
        let capped = Fidelity::select(&report, &quiet);
        console.print(&Text::new(format!(
            "{name:<18} {:<9} capped: {}",
            level.name(),
            capped.name()
        )));
    }
    // Facts can also be given directly.
    let facts = FidelityFacts {
        unicode: true,
        color: false,
        interactive: false,
        animation: false,
    };
    assert_eq!(
        Fidelity::select(&facts, &Policy::default()),
        Fidelity::Plain
    );
}

The level selected for four environments

Degrade any renderable

Degrade renders any renderable, then post-processes its segments for a level:

  • Styled strips colour and keeps attributes;
  • Plain strips all styles;
  • Ascii also replaces glyphs with ASCII look-alikes of the same cell width (╭ becomes +, ✔ becomes v, → becomes >), and ? where none exists.

Without .level(…) it selects the level from the console it renders on. .policy(…) caps that selection. Degrade::new(value) takes ownership, and Degrade::borrowed(&value) borrows.

fn show_degrade(console: &Console) {
    let panel = Panel::new(Box::new(
        Text::from_markup(
            "[bold green]✔ ok[/]  [red]✖ failed[/]  → [link=https://ci.example/1]log[/]",
        )
        .unwrap(),
    ))
    .title("CI");
    for level in [
        Fidelity::Rich,
        Fidelity::Styled,
        Fidelity::Plain,
        Fidelity::Ascii,
    ] {
        console.print(&Text::new(level.name()));
        // Without `.level(…)`, Degrade selects from the console it renders on.
        console.print(&Degrade::borrowed(&panel).level(level));
    }
}

One panel at four levels

The helpers are public too: strip_color, strip_styles, ascii_fallback and degrade_segments work on segments, ascii_text on a string, and style_without_color on a style.

Adaptive renderables

Stripping works for any renderable, but a renderable can do better by offering its own forms. Implement Degradable: levels() lists the levels it renders natively, and render_at(level, …) renders one. Adaptive picks the best offered level that is not above the selected level. When every offered level is above it, Adaptive renders the lowest one and degrades it generically.

/// A status line with its own plain and ASCII forms.
struct Status {
    ok: usize,
    failed: usize,
}

impl Degradable for Status {
    fn levels(&self) -> &[Fidelity] {
        &[Fidelity::Rich, Fidelity::Plain, Fidelity::Ascii]
    }

    fn render_at(
        &self,
        level: Fidelity,
        console: &Console,
        options: &ConsoleOptions,
    ) -> Vec<Segment> {
        let (ok, failed) = (self.ok, self.failed);
        let text = match level {
            Fidelity::Ascii => Text::new(format!("[OK] {ok} passed, [FAIL] {failed} failed")),
            Fidelity::Plain => Text::new(format!("✔ {ok} passed, ✖ {failed} failed")),
            _ => Text::from_markup(&format!(
                "[green]✔ {ok}[/] passed, [bold red]✖ {failed}[/] failed"
            ))
            .unwrap(),
        };
        text.rich_render(console, options)
    }
}

fn show_adaptive(console: &Console) {
    for level in [Fidelity::Animated, Fidelity::Styled, Fidelity::Ascii] {
        // Styled is not offered, so Plain (the best level below it) renders.
        let status = Adaptive::new(Status { ok: 41, failed: 1 }).level(level);
        let (selected, rendered) = status.resolve(console);
        console.print(&Text::new(format!(
            "{:<8} → {:<6}",
            selected.name(),
            rendered.name()
        )));
        console.print(&status);
    }
}

Selected and rendered levels for a status line

resolve(&console) returns both the selected and the rendered level, which is useful in tests.

rich doctor

rich doctor ends with the capability table above, one row per capability with its source. rich doctor --report json includes the same data under capabilities. It never probes the terminal, so it is safe in scripts.

rich doctor
RICH_COLOR=256 RICH_UNICODE=0 rich doctor   # see overrides take effect
rich doctor --report json > doctor.json

See Using the CLI for the rest of its report.

The clipboard (OSC 52)

rich_ext::clipboard decides whether to copy through OSC 52 the same way, with provenance, from the same Environment. It is its own check rather than a field of Report, since the rows rich doctor shows stay as they were. It is always off when the output is not a terminal; on a terminal RICH_CLIPBOARD=0|1 overrides it. Otherwise it is off on TERM=dumb and inside tmux or screen, and on only for terminals known to take OSC 52:

use rich_ext::capabilities::MapEnvironment;
use rich_ext::clipboard::{self, Clipboard};

let env = MapEnvironment::tty().var("TERM_PROGRAM", "WezTerm");
let field = clipboard::detect(&env);
assert!(field.value);
assert_eq!(field.reason, "TERM_PROGRAM=WezTerm");

let mut out = Vec::new();
Clipboard::detect(&env).copy(&mut out, "hi")?;
assert_eq!(out, b"\x1b]52;c;aGk=\x07");

CopyFormat writes a table row or cell as text, CSV or JSON for copying. Interactive components copy through rich_interact::clipboard: see Explorers, copying and live lists.

Gotchas

  • Capabilities are not preferences. A terminal that can show colour may belong to someone who asked for none. Combine a report with an AccessibilityPolicy: its fidelity_policy() is a ceiling for Fidelity::select.
  • Sixel is inferred, not confirmed. to_target_capabilities reports Sixel as confirmed only when an override or RICH_* variable says so.
  • Core TargetCapabilities has no animation fact. When selecting from them, an interactive target counts as able to animate.

See also