Skip to content

Accessibility

Terminal output is often read by people who cannot rely on colour, borders or animation. Screen-reader users hear box-drawing characters read aloud. Colour-blind users cannot tell red from green. Some users turn motion off. rich_ext::a11y has three parts:

  • Semantic text (AccessibleText): the content of a table, tree, panel, rule, text or diagnostic in reading order, without decoration.
  • Policies (AccessibilityPolicy): the user's preferences, read from RICH_A11Y and NO_COLOR, applied to themes, fidelity and status symbols.
  • Theme checks (check_theme): WCAG contrast, styles that differ only by colour, and pairs that look alike under colour vision deficiencies.

The examples come from guide_a11y.rs. The module needs no feature; the JSON form of findings needs serde:

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

Semantic text

A table as the eye sees it:

A rendered table

accessible_text(width) returns the same content as linear, undecorated text. Tables name their columns in each row, trees become indented lists, panels keep their title, and links read as text <url>:

fn show_semantic(console: &Console) {
    let table = services();
    let mut tree = Tree::new("deploy");
    tree.add("build").add("compile");
    tree.add("upload");
    let panel = Panel::new(Box::new(Text::new("All checks passed"))).title("CI");
    let link = Text::from_markup("See the [link=https://ci.example/42]build log[/].").unwrap();
    let diagnostic = Diagnostic::error("mismatched types")
        .code("E0308")
        .location(Location::new("src/main.rs", Some(4), Some(18)))
        .help("change the type to `u64`");

    // The same content, in reading order, without borders or guides.
    for text in [
        table.accessible_text(80),
        tree.accessible_text(80),
        panel.accessible_text(80),
        link.accessible_text(80),
        diagnostic.accessible_text(80),
    ] {
        console.print(&Text::new(text));
        console.print(&Text::new(""));
    }
    // Any other renderable: rendered plainly, decoration dropped.
    let rule = rich::Rule::new("Summary");
    assert_eq!(semantic_text(&rule, 40), "Summary");
}

The same content as semantic text

AccessibleText is implemented for core Table, Tree, Panel, Rule and Text, for markup strings (str), for Diagnostic, and for every chart, which says what it shows in words: its ranges, key values and trends. semantic_text(&renderable, width) handles any other renderable: it renders plainly and drops the decoration.

Use it when you know the output goes to a screen reader or a log, for example under the screen-reader policy below. Implement AccessibleText for your own renderables to give them a reading order.

How structure is recovered

Core Table, Tree, Panel and Rule keep their contents private, as upstream does. The implementations here recover the structure from a plain render. Columns come from border junctions, tree depth from the guides, and titles from the borders. A cell that contains a border character, a table with show_header(false) and show_lines(true), or a Table::grid() can be misread.

Policies

AccessibilityPolicy::from_env(&env) reads the user's preferences:

  • NO_COLOR (non-empty) turns on monochrome.
  • RICH_A11Y is a comma-separated list of screen-reader, reduced-motion, no-animation, high-contrast, compact and monochrome, plus a symbol set: ascii-symbols or word-symbols. Unknown items are ignored and listed in policy.warnings.

Pass SystemEnvironment for the real process, or a MapEnvironment in tests (see Capabilities). Presets exist for the common cases: screen_reader(), reduced_motion(), high_contrast() and monochrome().

fn show_policy(console: &Console) {
    let environments = [
        ("(nothing set)", MapEnvironment::new()),
        ("NO_COLOR=1", MapEnvironment::new().var("NO_COLOR", "1")),
        (
            "RICH_A11Y=screen-reader",
            MapEnvironment::new().var("RICH_A11Y", "screen-reader"),
        ),
        (
            "RICH_A11Y=reduced-motion,ascii-symbols",
            MapEnvironment::new().var("RICH_A11Y", "reduced-motion,ascii-symbols"),
        ),
    ];
    let mut table = Table::new();
    for header in ["Environment", "Ceiling", "Ok", "Error", "Warning"] {
        table.add_column(header);
    }
    for (name, env) in environments {
        let policy = AccessibilityPolicy::from_env(&env);
        table.add_row(&[
            name,
            policy.fidelity_ceiling().name(),
            policy.status(Status::Ok),
            policy.status(Status::Error),
            policy.status(Status::Warning),
        ]);
    }
    console.print(&table);

    // Unknown items are kept as warnings rather than failing.
    let policy = AccessibilityPolicy::from_env(&MapEnvironment::new().var("RICH_A11Y", "loud"));
    assert_eq!(policy.warnings, ["ignored RICH_A11Y item \"loud\""]);
}

The ceiling and status symbols for four environments

Applying a policy

Method Effect
theme(&theme) High contrast drops dim and turns black, grey and dark blue foregrounds into readable ones. Monochrome removes colours but keeps bold, underline and other attributes.
console_builder(builder) Applies the adjusted theme, sets no colour when monochrome, and turns off emoji and highlighting for screen readers
fidelity_ceiling() The highest fidelity: screen reader → Plain, monochrome → Styled, no animation or reduced motion → Rich, otherwise Animated
fidelity_policy() A fidelity Policy with that ceiling and animation allowed or not
status(Status::Ok) The status marker for this policy's symbol set
fn build_console() -> Console {
    let policy = AccessibilityPolicy::from_env(&rich_ext::capabilities::SystemEnvironment);
    // A theme without dim/grey (high contrast) or colour (monochrome), and no
    // emoji or highlighting for screen readers.
    let console = policy.console_builder(Console::builder()).build();
    // Cap fidelity-aware renderables (`Degrade`, `Adaptive`) too.
    let _ceiling: Fidelity = policy.fidelity_ceiling();
    let _fidelity_policy = policy.fidelity_policy();
    console
}

fn status_line(policy: &AccessibilityPolicy, ok: bool, message: &str) -> String {
    // Meaning never depends on colour: a symbol and a word, a tag, or a word.
    let status = if ok { Status::Ok } else { Status::Error };
    status.label(policy.status_symbols, message)
}

Status symbols

A status must not depend on colour alone. Every SymbolSet carries the meaning in text: Unicode pairs a symbol with a word (✔ ok), Ascii uses a bracketed tag ([OK]), and Words a word only (ok:). Status covers Ok, Warning, Error, Info, Pending and Skipped, and Status::label(set, message) prefixes a message:

The three symbol sets

The lint and capability matrix tools flag output that tells statuses apart only by colour, or uses Unicode symbols on ASCII terminals.

Theme checks

check_theme(&theme, &options) checks every style that sets a colour, dim or reverse:

  • Low contrast: the WCAG contrast ratio against each background in options.backgrounds (default rich's white export palette and Monokai). Below min_ratio (4.5, WCAG AA for normal text) is a warning, and below error_ratio (3.0) an error. The finding suggests the nearest colour that passes.
  • Colour-only distinctions: two styles in a group that become identical without colour. The default groups are the logging levels, repr.bool_true / repr.bool_false, and error / warning / info / success.
  • Colour-blind confusion: pairs in a group whose colours are closer than cvd_threshold (CIEDE2000 ΔE 10) under simulated protanopia, deuteranopia or tritanopia (Viénot/Brettel simulation).

Findings are sorted by style name, so a report is stable across runs. ContrastReport renders them:

fn app_theme() -> Theme {
    Theme::from_styles(
        [
            ("app.title", "bold #1e90ff"),
            ("app.muted", "#9e9e9e"),
            ("app.ok", "#2e8b57"),
            ("app.fail", "#b22222"),
            ("app.link", "underline #6495ed"),
        ],
        false,
    )
    .expect("valid styles")
}

fn show_contrast(console: &Console) {
    let options = CheckOptions {
        // Pairs that must stay distinguishable from each other.
        groups: vec![vec!["app.ok".into(), "app.fail".into()]],
        ..CheckOptions::default()
    };
    let findings = check_theme(&app_theme(), &options);
    for finding in &findings {
        if let FindingKind::LowContrast { ratio, .. } = finding.kind {
            assert!(ratio < options.min_ratio);
        }
    }
    console.print(&ContrastReport::new(&findings));
}

Contrast findings with suggested colours

Each Finding has the style name, a FindingKind (with the ratio and colours, or the pair and deficiency), a Severity and a suggestion. describe() gives a one-line summary. The colour maths is public in a11y::contrast: contrast_ratio, relative_luminance, simulate, delta_e and suggest_color.

JSON for CI

With the serde feature, findings serialize, so a CI job can fail on errors or post annotations:

fn contrast_json() -> String {
    // Needs the `serde` feature.
    let findings = check_theme(&app_theme(), &CheckOptions::default());
    serde_json::to_string_pretty(&findings).expect("serializable")
}
{
  "style_name": "app.fail",
  "kind": {
    "kind": "low_contrast",
    "ratio": 2.93,
    "fg": "#b22222",
    "bg": "#0c0c0c"
  },
  "severity": "error",
  "suggestion": "use #db4141 (4.51:1) on #0c0c0c"
}

Policies, capability reports and ANSI explanations serialize the same way.

Gotchas

  • NO_COLOR is a preference, not a capability. It changes both the capability report (colour: none) and the policy (monochrome), so check both, as the capabilities page explains.
  • Semantic text from a render is a best effort for core types; see the note above. Your own AccessibleText implementations can be exact.
  • Contrast depends on the background. A style that passes on a dark palette can fail on a light one. Keep both default backgrounds unless you control the terminal.

See also