Skip to main content

rich_ext/
fidelity.rs

1//! Graceful degradation: pick how rich output may be, then render at that level.
2//!
3//! [`Fidelity`] runs from [`Animated`](Fidelity::Animated) down to
4//! [`Ascii`](Fidelity::Ascii). [`Fidelity::select`] maps capabilities onto a
5//! level; a [`Degradable`] renderable says which levels it renders natively and
6//! [`Adaptive`] picks the best one. [`Degrade`] makes any renderable degradable
7//! by post-processing its segments with [`strip_color`], [`strip_styles`] and
8//! [`ascii_fallback`].
9
10use crate::capabilities::Report;
11use rich::cells::char_cell_width;
12use rich::protocol::{ConsoleEnvironment, TargetCapabilities};
13use rich::{Console, ConsoleOptions, Renderable, Segment, Style};
14
15/// How much of the terminal's feature set output may use. `Ord` runs from low
16/// ([`Ascii`](Self::Ascii)) to high ([`Animated`](Self::Animated)).
17#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize))]
19#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
20pub enum Fidelity {
21    /// ASCII-only glyphs, no styles.
22    Ascii,
23    /// Unicode glyphs, no styles.
24    Plain,
25    /// Unicode with bold/italic/underline, but no colour.
26    Styled,
27    /// Static full colour.
28    Rich,
29    /// Full colour plus live updates and animation.
30    Animated,
31}
32
33impl Fidelity {
34    /// Every level, high to low.
35    pub const ALL: [Fidelity; 5] = [
36        Fidelity::Animated,
37        Fidelity::Rich,
38        Fidelity::Styled,
39        Fidelity::Plain,
40        Fidelity::Ascii,
41    ];
42
43    /// The lowercase name.
44    pub fn name(self) -> &'static str {
45        match self {
46            Fidelity::Animated => "animated",
47            Fidelity::Rich => "rich",
48            Fidelity::Styled => "styled",
49            Fidelity::Plain => "plain",
50            Fidelity::Ascii => "ascii",
51        }
52    }
53
54    /// Select a level. Rules, in order:
55    ///
56    /// 1. no Unicode → `Ascii`;
57    /// 2. no colour → `Styled` on an interactive terminal (attributes still
58    ///    work there; `NO_COLOR` only removes colour), `Plain` elsewhere;
59    /// 3. colour, animation allowed and interactive → `Animated`;
60    /// 4. otherwise → `Rich`.
61    ///
62    /// Then the policy's `ceiling` caps the result and its `floor` raises it
63    /// (the floor wins over capabilities: the caller insists).
64    pub fn select(source: &dyn FidelitySource, policy: &Policy) -> Fidelity {
65        let facts = source.facts();
66        let natural = if !facts.unicode {
67            Fidelity::Ascii
68        } else if !facts.color {
69            if facts.interactive {
70                Fidelity::Styled
71            } else {
72                Fidelity::Plain
73            }
74        } else if facts.animation && facts.interactive && policy.allow_animation {
75            Fidelity::Animated
76        } else {
77            Fidelity::Rich
78        };
79        policy.clamp(natural)
80    }
81
82    /// Select a level for a console: its render environment when one is
83    /// attached, else its own settings.
84    pub fn for_console(console: &Console, policy: &Policy) -> Fidelity {
85        match console.render_environment() {
86            Some(env) => Self::select(&env.capabilities(), policy),
87            None => Self::select(&ConsoleFacts(console), policy),
88        }
89    }
90}
91
92/// The facts [`Fidelity::select`] needs.
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub struct FidelityFacts {
95    pub unicode: bool,
96    pub color: bool,
97    pub interactive: bool,
98    pub animation: bool,
99}
100
101/// Anything fidelity can be selected from.
102pub trait FidelitySource {
103    fn facts(&self) -> FidelityFacts;
104}
105
106impl FidelitySource for FidelityFacts {
107    fn facts(&self) -> FidelityFacts {
108        *self
109    }
110}
111
112impl FidelitySource for Report {
113    fn facts(&self) -> FidelityFacts {
114        FidelityFacts {
115            unicode: self.unicode.value,
116            color: self.color.value.color_system().is_some(),
117            interactive: self.interactive.value,
118            animation: self.animation.value,
119        }
120    }
121}
122
123/// Core capabilities carry no animation fact; an interactive target animates.
124impl FidelitySource for TargetCapabilities {
125    fn facts(&self) -> FidelityFacts {
126        FidelityFacts {
127            unicode: self.unicode,
128            color: self.color_system.is_some(),
129            interactive: self.interactive,
130            animation: self.interactive,
131        }
132    }
133}
134
135struct ConsoleFacts<'a>(&'a Console);
136
137impl FidelitySource for ConsoleFacts<'_> {
138    fn facts(&self) -> FidelityFacts {
139        let c = self.0;
140        FidelityFacts {
141            unicode: !c.ascii_only(),
142            color: c.color_system().is_some() && !c.no_color(),
143            interactive: c.is_terminal(),
144            animation: c.is_terminal(),
145        }
146    }
147}
148
149/// Caller limits on [`Fidelity::select`].
150#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub struct Policy {
152    /// Never go above this level.
153    pub ceiling: Option<Fidelity>,
154    /// Never go below this level.
155    pub floor: Option<Fidelity>,
156    /// Allow [`Fidelity::Animated`] at all.
157    pub allow_animation: bool,
158}
159
160impl Default for Policy {
161    fn default() -> Self {
162        Self {
163            ceiling: None,
164            floor: None,
165            allow_animation: true,
166        }
167    }
168}
169
170impl Policy {
171    #[must_use]
172    pub fn ceiling(mut self, level: Fidelity) -> Self {
173        self.ceiling = Some(level);
174        self
175    }
176    #[must_use]
177    pub fn floor(mut self, level: Fidelity) -> Self {
178        self.floor = Some(level);
179        self
180    }
181    /// Apply the ceiling, then the floor.
182    pub fn clamp(&self, level: Fidelity) -> Fidelity {
183        let level = self.ceiling.map_or(level, |c| level.min(c));
184        self.floor.map_or(level, |f| level.max(f))
185    }
186}
187
188/// A renderable that renders natively at some fidelity levels.
189pub trait Degradable {
190    /// The levels [`render_at`](Self::render_at) supports.
191    fn levels(&self) -> &[Fidelity];
192    /// Render at `level`, one of [`levels`](Self::levels).
193    fn render_at(
194        &self,
195        level: Fidelity,
196        console: &Console,
197        options: &ConsoleOptions,
198    ) -> Vec<Segment>;
199}
200
201/// Renders a [`Degradable`] at the best supported level not above the selected
202/// one. When every supported level is above it, the lowest supported level is
203/// rendered and then degraded generically.
204pub struct Adaptive<T> {
205    inner: T,
206    level: Option<Fidelity>,
207    policy: Policy,
208}
209
210impl<T: Degradable> Adaptive<T> {
211    pub fn new(inner: T) -> Self {
212        Self {
213            inner,
214            level: None,
215            policy: Policy::default(),
216        }
217    }
218    /// Force the selected level instead of deriving it from the console.
219    #[must_use]
220    pub fn level(mut self, level: Fidelity) -> Self {
221        self.level = Some(level);
222        self
223    }
224    /// Limits applied when the level is derived from the console.
225    #[must_use]
226    pub fn policy(mut self, policy: Policy) -> Self {
227        self.policy = policy;
228        self
229    }
230    /// The level the console would get and the level actually rendered.
231    pub fn resolve(&self, console: &Console) -> (Fidelity, Fidelity) {
232        let selected = self
233            .level
234            .unwrap_or_else(|| Fidelity::for_console(console, &self.policy));
235        let levels = self.inner.levels();
236        let chosen = levels
237            .iter()
238            .copied()
239            .filter(|l| *l <= selected)
240            .max()
241            .or_else(|| levels.iter().copied().min())
242            .unwrap_or(selected);
243        (selected, chosen)
244    }
245}
246
247impl<T: Degradable> Renderable for Adaptive<T> {
248    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
249        let (selected, chosen) = self.resolve(console);
250        let segments = self.inner.render_at(chosen, console, options);
251        if chosen > selected {
252            degrade_segments(segments, selected)
253        } else {
254            segments
255        }
256    }
257}
258
259/// Degrade already-rendered segments to `level`.
260pub fn degrade_segments(segments: Vec<Segment>, level: Fidelity) -> Vec<Segment> {
261    match level {
262        Fidelity::Animated | Fidelity::Rich => segments,
263        Fidelity::Styled => strip_color(segments),
264        Fidelity::Plain => strip_styles(segments),
265        Fidelity::Ascii => ascii_fallback(strip_styles(segments)),
266    }
267}
268
269/// Any renderable, made degradable by post-processing its segments.
270pub struct Degrade<R> {
271    inner: R,
272    level: Option<Fidelity>,
273    policy: Policy,
274}
275
276impl<R: Renderable> Degrade<R> {
277    pub fn new(inner: R) -> Self {
278        Self {
279            inner,
280            level: None,
281            policy: Policy::default(),
282        }
283    }
284    /// Force a level instead of deriving it from the console.
285    #[must_use]
286    pub fn level(mut self, level: Fidelity) -> Self {
287        self.level = Some(level);
288        self
289    }
290    #[must_use]
291    pub fn policy(mut self, policy: Policy) -> Self {
292        self.policy = policy;
293        self
294    }
295}
296
297/// A borrowed renderable, so a `&dyn Renderable` can be wrapped.
298pub struct Borrowed<'a>(pub &'a dyn Renderable);
299
300impl Renderable for Borrowed<'_> {
301    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
302        self.0.rich_render(console, options)
303    }
304    fn measure(&self, console: &Console, options: &ConsoleOptions) -> rich::measure::Measurement {
305        self.0.measure(console, options)
306    }
307}
308
309impl<'a> Degrade<Borrowed<'a>> {
310    /// Degrade a borrowed renderable.
311    pub fn borrowed(inner: &'a dyn Renderable) -> Self {
312        Self::new(Borrowed(inner))
313    }
314}
315
316impl<R: Renderable> Degradable for Degrade<R> {
317    fn levels(&self) -> &[Fidelity] {
318        &Fidelity::ALL
319    }
320    fn render_at(
321        &self,
322        level: Fidelity,
323        console: &Console,
324        options: &ConsoleOptions,
325    ) -> Vec<Segment> {
326        degrade_segments(self.inner.rich_render(console, options), level)
327    }
328}
329
330impl<R: Renderable> Renderable for Degrade<R> {
331    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
332        let level = self
333            .level
334            .unwrap_or_else(|| Fidelity::for_console(console, &self.policy));
335        self.render_at(level, console, options)
336    }
337    fn measure(&self, console: &Console, options: &ConsoleOptions) -> rich::measure::Measurement {
338        self.inner.measure(console, options)
339    }
340}
341
342/// Style attribute names by core index (`Style::attr`).
343pub(crate) const ATTR_NAMES: [&str; 13] = [
344    "bold",
345    "dim",
346    "italic",
347    "underline",
348    "blink",
349    "blink2",
350    "reverse",
351    "conceal",
352    "strike",
353    "underline2",
354    "frame",
355    "encircle",
356    "overline",
357];
358
359/// `style` with its colours removed; attributes and link are kept.
360pub fn style_without_color(style: &Style) -> Style {
361    let words: Vec<String> = ATTR_NAMES
362        .iter()
363        .enumerate()
364        .filter_map(|(i, name)| match style.attr(i) {
365            Some(true) => Some((*name).to_owned()),
366            Some(false) => Some(format!("not {name}")),
367            None => None,
368        })
369        .collect();
370    let base = if words.is_empty() {
371        Style::new()
372    } else {
373        Style::parse(&words.join(" ")).unwrap_or_default()
374    };
375    base.update_link(style.link().map(str::to_owned))
376}
377
378/// Remove foreground and background colours, keeping attributes and links.
379pub fn strip_color(segments: Vec<Segment>) -> Vec<Segment> {
380    segments
381        .into_iter()
382        .map(|mut s| {
383            s.style = s
384                .style
385                .as_ref()
386                .map(style_without_color)
387                .filter(|st| !st.is_null());
388            s
389        })
390        .collect()
391}
392
393/// Remove every style and drop control segments.
394pub fn strip_styles(segments: Vec<Segment>) -> Vec<Segment> {
395    segments
396        .into_iter()
397        .filter(|s| !s.control)
398        .map(|mut s| {
399            s.style = None;
400            s
401        })
402        .collect()
403}
404
405/// Replace non-ASCII glyphs with ASCII of the same cell width: box drawing,
406/// blocks, arrows, bullets, check/cross marks and common punctuation map to a
407/// look-alike; anything else becomes `?` per cell.
408pub fn ascii_fallback(segments: Vec<Segment>) -> Vec<Segment> {
409    segments
410        .into_iter()
411        .map(|mut s| {
412            if !s.control && !s.text.is_ascii() {
413                s.text = ascii_text(&s.text).into();
414            }
415            s
416        })
417        .collect()
418}
419
420/// [`ascii_fallback`] for a string.
421pub fn ascii_text(text: &str) -> String {
422    let mut out = String::with_capacity(text.len());
423    for c in text.chars() {
424        if c.is_ascii() {
425            out.push(c);
426        } else if let Some(a) = ascii_char(c) {
427            out.push(a);
428            for _ in 1..char_cell_width(c) {
429                out.push(' ');
430            }
431        } else {
432            // Zero-width marks vanish; wide glyphs keep their width.
433            for _ in 0..char_cell_width(c) {
434                out.push('?');
435            }
436        }
437    }
438    out
439}
440
441/// The ASCII look-alike of one glyph, when there is one.
442pub fn ascii_char(c: char) -> Option<char> {
443    Some(match c {
444        // Box drawing: horizontals, verticals, then every corner and junction.
445        '─' | '━' | '┄' | '┅' | '┈' | '┉' | '╌' | '╍' | '╴' | '╶' | '╸' | '╺' | '╼' | '╾' => {
446            '-'
447        }
448        '═' => '=',
449        '│' | '┃' | '┆' | '┇' | '┊' | '┋' | '╎' | '╏' | '║' | '╵' | '╷' | '╹' | '╻' | '╽' | '╿' => {
450            '|'
451        }
452        '╱' => '/',
453        '╲' => '\\',
454        '╳' => 'X',
455        '\u{2500}'..='\u{257f}' => '+',
456        // Blocks and shades.
457        '░' => '.',
458        '▒' => ':',
459        '▓' | '█' => '#',
460        '▁' | '▂' => '_',
461        '\u{2580}'..='\u{259f}' => '#',
462        // Braille (spinners, sparklines).
463        '\u{2800}' => ' ',
464        '\u{2801}'..='\u{28ff}' => '.',
465        // Arrows and triangles.
466        '→' | '⇒' | '⟶' | '➜' | '➔' | '▶' | '►' | '▸' | '❯' | '›' | '»' => '>',
467        '←' | '⇐' | '⟵' | '◀' | '◄' | '◂' | '❮' | '‹' | '«' => '<',
468        '↑' | '⇑' | '▲' | '▴' => '^',
469        '↓' | '⇓' | '▼' | '▾' => 'v',
470        '↔' | '⇔' => '-',
471        '↷' | '↻' | '⟳' => '~',
472        // Bullets and markers.
473        '•' | '●' | '◉' | '▪' | '■' | '◆' | '★' | '∙' | '⁃' => '*',
474        '○' | '◦' | '◯' | '□' | '◇' | '☐' => 'o',
475        '·' | '…' | '⋯' => '.',
476        '✓' | '✔' | '☑' | '✅' => 'v',
477        '✗' | '✘' | '✕' | '✖' | '×' | '☒' | '❌' => 'x',
478        '⚠' => '!',
479        'ℹ' => 'i',
480        // Punctuation.
481        '‘' | '’' | '′' => '\'',
482        '“' | '”' | '″' => '"',
483        '‐' | '‑' | '‒' | '–' | '—' | '―' | '−' => '-',
484        '\u{a0}' | '\u{2002}'..='\u{200a}' => ' ',
485        _ => return None,
486    })
487}