use crate::capabilities::Environment;
use crate::fidelity::{style_without_color, Fidelity, ATTR_NAMES};
use rich::color::ColorType;
use rich::console::ConsoleBuilder;
use rich::{Color, Style, Theme};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum SymbolSet {
#[default]
Unicode,
Ascii,
Words,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum Status {
Ok,
Warning,
Error,
Info,
Pending,
Skipped,
}
impl Status {
pub const ALL: [Status; 6] = [
Status::Ok,
Status::Warning,
Status::Error,
Status::Info,
Status::Pending,
Status::Skipped,
];
pub fn word(self) -> &'static str {
match self {
Status::Ok => "ok",
Status::Warning => "warning",
Status::Error => "error",
Status::Info => "info",
Status::Pending => "pending",
Status::Skipped => "skipped",
}
}
pub fn symbol(self, set: SymbolSet) -> &'static str {
match (set, self) {
(SymbolSet::Unicode, Status::Ok) => "✔ ok",
(SymbolSet::Unicode, Status::Warning) => "⚠ warning",
(SymbolSet::Unicode, Status::Error) => "✖ error",
(SymbolSet::Unicode, Status::Info) => "ℹ info",
(SymbolSet::Unicode, Status::Pending) => "… pending",
(SymbolSet::Unicode, Status::Skipped) => "↷ skipped",
(SymbolSet::Ascii, Status::Ok) => "[OK]",
(SymbolSet::Ascii, Status::Warning) => "[WARN]",
(SymbolSet::Ascii, Status::Error) => "[ERROR]",
(SymbolSet::Ascii, Status::Info) => "[INFO]",
(SymbolSet::Ascii, Status::Pending) => "[PENDING]",
(SymbolSet::Ascii, Status::Skipped) => "[SKIP]",
(SymbolSet::Words, Status::Ok) => "ok:",
(SymbolSet::Words, Status::Warning) => "warning:",
(SymbolSet::Words, Status::Error) => "error:",
(SymbolSet::Words, Status::Info) => "info:",
(SymbolSet::Words, Status::Pending) => "pending:",
(SymbolSet::Words, Status::Skipped) => "skipped:",
}
}
pub fn label(self, set: SymbolSet, message: &str) -> String {
format!("{} {message}", self.symbol(set))
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct AccessibilityPolicy {
pub compact: bool,
pub screen_reader: bool,
pub no_animation: bool,
pub reduced_motion: bool,
pub high_contrast: bool,
pub monochrome: bool,
pub status_symbols: SymbolSet,
pub warnings: Vec<String>,
}
impl AccessibilityPolicy {
pub fn screen_reader() -> Self {
Self {
compact: true,
screen_reader: true,
no_animation: true,
reduced_motion: true,
status_symbols: SymbolSet::Words,
..Self::default()
}
}
pub fn reduced_motion() -> Self {
Self {
no_animation: true,
reduced_motion: true,
..Self::default()
}
}
pub fn high_contrast() -> Self {
Self {
high_contrast: true,
..Self::default()
}
}
pub fn monochrome() -> Self {
Self {
monochrome: true,
status_symbols: SymbolSet::Ascii,
..Self::default()
}
}
pub fn from_env(env: &dyn Environment) -> Self {
let mut policy = Self::default();
if env.var("NO_COLOR").is_some_and(|v| !v.is_empty()) {
policy.monochrome = true;
}
let list = env.var("RICH_A11Y").unwrap_or_default();
for item in list.split(',').map(str::trim).filter(|i| !i.is_empty()) {
match item.to_ascii_lowercase().as_str() {
"screen-reader" => {
let warnings = std::mem::take(&mut policy.warnings);
let monochrome = policy.monochrome;
let high_contrast = policy.high_contrast;
policy = Self::screen_reader();
policy.warnings = warnings;
policy.monochrome |= monochrome;
policy.high_contrast |= high_contrast;
}
"reduced-motion" => policy.reduced_motion = true,
"no-animation" => policy.no_animation = true,
"high-contrast" => policy.high_contrast = true,
"compact" => policy.compact = true,
"monochrome" => policy.monochrome = true,
"ascii-symbols" => policy.status_symbols = SymbolSet::Ascii,
"word-symbols" => policy.status_symbols = SymbolSet::Words,
other => policy
.warnings
.push(format!("ignored RICH_A11Y item {other:?}")),
}
}
policy
}
pub fn fidelity_ceiling(&self) -> Fidelity {
if self.screen_reader {
Fidelity::Plain
} else if self.monochrome {
Fidelity::Styled
} else if self.no_animation || self.reduced_motion {
Fidelity::Rich
} else {
Fidelity::Animated
}
}
pub fn fidelity_policy(&self) -> crate::fidelity::Policy {
crate::fidelity::Policy {
ceiling: Some(self.fidelity_ceiling()),
floor: None,
allow_animation: !(self.no_animation || self.reduced_motion),
}
}
pub fn status(&self, status: Status) -> &'static str {
status.symbol(self.status_symbols)
}
pub fn theme(&self, theme: &Theme) -> Theme {
let mut out = Theme::new();
for name in theme.names() {
let Some(style) = theme.get(name) else {
continue;
};
let mut style = style.clone();
if self.high_contrast {
style = high_contrast_style(&style);
}
if self.monochrome {
style = style_without_color(&style);
}
out.insert(name, style);
}
out
}
pub fn console_builder(&self, builder: ConsoleBuilder) -> ConsoleBuilder {
self.console_builder_with_theme(builder, &Theme::default_theme())
}
pub fn console_builder_with_theme(
&self,
mut builder: ConsoleBuilder,
theme: &Theme,
) -> ConsoleBuilder {
builder = builder.theme(self.theme(theme));
if self.monochrome {
builder = builder.no_color(true);
}
if self.screen_reader {
builder = builder.emoji(false).highlight(false);
}
builder
}
}
fn is_grey(color: &Color) -> bool {
match color.kind {
ColorType::Standard | ColorType::Windows => matches!(color.number, Some(0 | 8)),
ColorType::EightBit => matches!(color.number, Some(0 | 8 | 16 | 232..=250)),
ColorType::Truecolor => color.triplet.is_some_and(|t| {
let (max, min) = (
t.red.max(t.green).max(t.blue),
t.red.min(t.green).min(t.blue),
);
max - min < 24 && max < 200
}),
ColorType::Default => false,
}
}
fn high_contrast_style(style: &Style) -> Style {
let words: Vec<String> = ATTR_NAMES
.iter()
.enumerate()
.filter(|(i, _)| *i != 1)
.filter_map(|(i, name)| match style.attr(i) {
Some(true) => Some((*name).to_owned()),
Some(false) => Some(format!("not {name}")),
None => None,
})
.collect();
let mut out = if words.is_empty() {
Style::new()
} else {
Style::parse(&words.join(" ")).unwrap_or_default()
};
if let Some(color) = style.color() {
let color = if is_grey(color) {
Color::default_color()
} else if matches!(color.kind, ColorType::Standard) && color.number == Some(4) {
Color::parse("bright_blue").unwrap_or_else(|_| color.clone())
} else {
color.clone()
};
out = out.with_color(color);
}
if let Some(bg) = style.bgcolor() {
out = out.with_bgcolor(bg.clone());
}
out.update_link(style.link().map(str::to_owned))
}