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::capabilitiesdecides what the output can use (colour depth, Unicode, OSC 8 links, graphics, size, interactivity, animation) and records where each answer came from.rich_ext::fidelityturns those answers into one level, fromAnimateddown toAscii, 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:
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));
}
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_COLORgives none.FORCE_COLORforces 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 aTERMcontaining256gets 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=1there. - Graphics and Sixel: kitty, iTerm-style inline images, or the Sixel heuristic, on a terminal only.
- Size:
RICH_WIDTH/RICH_HEIGHT, thenCOLUMNS/LINES, then the terminal, then 80×25. - Animation: an interactive terminal, not CI, not
TERM=dumb, and no reduced-motion preference inRICH_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));
}
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);
}
}
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:
- no Unicode gives
Ascii; - no colour gives
Styledon an interactive terminal (attributes still work there;NO_COLORremoves only colour) andPlainelsewhere; - colour, animation allowed and interactive gives
Animated; - 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
);
}
Degrade any renderable¶
Degrade renders any renderable, then post-processes its segments for a
level:
Styledstrips colour and keeps attributes;Plainstrips all styles;Asciialso replaces glyphs with ASCII look-alikes of the same cell width (╭becomes+,✔becomesv,→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));
}
}
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);
}
}
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: itsfidelity_policy()is a ceiling forFidelity::select. - Sixel is inferred, not confirmed.
to_target_capabilitiesreports Sixel as confirmed only when an override orRICH_*variable says so. - Core
TargetCapabilitieshas no animation fact. When selecting from them, an interactive target counts as able to animate.
See also¶
rich_ext::capabilitieson docs.rsrich_ext::fidelityon docs.rs- Accessibility: user preferences such as
NO_COLORandRICH_A11Y - Quality assurance: render across 16 capability profiles, and explain colour and glyph fallbacks