Skip to main content

rich_art/
lib.rs

1//! # rich-art
2//!
3//! ASCII-art renderables for the [`rich`] terminal library — currently
4//! **FIGlet-style text banners** (in the spirit of `figlet(6)` / `pyfiglet`).
5//!
6//! This crate is **not** a port of anything upstream. `rich` itself has no
7//! banner support, so this is a local feature and lives outside the faithful
8//! mirror (see `AGENTS.md`). It depends only on [`rich`] — for the
9//! [`Renderable`] trait — so it can be lifted into its own repository unchanged.
10//!
11//! ```no_run
12//! use rich::Console;
13//! use rich_art::Figlet;
14//!
15//! let console = Console::builder().build();
16//! console.print(&Figlet::new("Hello"));
17//! ```
18//!
19//! A banner is a normal renderable, so it composes with the rest of `rich` —
20//! wrap it in a `Panel`, style it, export it to SVG, and so on.
21
22pub mod figlet;
23
24#[cfg(feature = "image")]
25pub mod ascii;
26
27#[cfg(feature = "image")]
28pub mod block;
29
30#[cfg(feature = "image")]
31pub mod imagediff;
32
33#[cfg(feature = "sixel")]
34pub mod sixel;
35
36/// The `image` crate, re-exported so callers can decode files without taking
37/// their own dependency on it (and without a version skew against ours).
38#[cfg(feature = "image")]
39pub use image;
40
41#[cfg(feature = "gif")]
42pub mod gif;
43
44#[cfg(feature = "gif")]
45pub mod stage;
46
47pub use crate::figlet::{FigletFont, FontError, Justify};
48
49#[cfg(feature = "image")]
50pub use crate::ascii::{AsciiArt, DEFAULT_RAMP};
51
52#[cfg(feature = "image")]
53pub use crate::block::BlockArt;
54
55#[cfg(feature = "sixel")]
56pub use crate::sixel::SixelArt;
57
58#[cfg(feature = "image")]
59pub use crate::imagediff::{diff, DiffError, DiffReport, DiffSettings, Region};
60
61#[cfg(feature = "gif")]
62pub use crate::gif::{AnimatedArt, Repeat};
63
64#[cfg(feature = "gif")]
65pub use crate::stage::{Stage, Until};
66
67use rich::console::{Console, ConsoleOptions};
68use rich::protocol::Renderable;
69use rich::segment::Segment;
70use rich::style::Style;
71
72/// A block of text rendered as a FIGlet banner.
73///
74/// Sizing follows the console: the banner lays out to `options.max_width`, so a
75/// long line wraps onto further banner rows exactly as `figlet` would.
76pub struct Figlet {
77    text: String,
78    font: FigletFont,
79    justify: Justify,
80    style: Option<Style>,
81    /// Overrides the console width when set.
82    width: Option<usize>,
83}
84
85impl Figlet {
86    /// A banner in the bundled `standard` font.
87    pub fn new(text: impl Into<String>) -> Self {
88        Figlet {
89            text: text.into(),
90            font: FigletFont::standard(),
91            justify: Justify::Left,
92            style: None,
93            width: None,
94        }
95    }
96
97    /// Use an explicit font (see [`FigletFont::parse`]).
98    pub fn font(mut self, font: FigletFont) -> Self {
99        self.font = font;
100        self
101    }
102
103    /// Position the banner within the available width.
104    pub fn justify(mut self, justify: Justify) -> Self {
105        self.justify = justify;
106        self
107    }
108
109    /// Paint the banner in a style.
110    pub fn style(mut self, style: Style) -> Self {
111        self.style = Some(style);
112        self
113    }
114
115    /// Lay out to an explicit width instead of the console's.
116    pub fn width(mut self, width: usize) -> Self {
117        self.width = Some(width);
118        self
119    }
120
121    /// The banner as plain text (the `figlet(1)` output), without rendering it
122    /// through a console.
123    pub fn to_text(&self, width: usize) -> String {
124        figlet::render(&self.text, &self.font, width, self.justify)
125    }
126}
127
128impl Renderable for Figlet {
129    fn rich_render(&self, _console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
130        let width = self.width.unwrap_or(options.max_width);
131        let banner = self.to_text(width);
132        // `render` always terminates each row with a newline; split it back into
133        // lines so the console controls the final separator.
134        let lines: Vec<&str> = banner
135            .strip_suffix('\n')
136            .unwrap_or(&banner)
137            .split('\n')
138            .collect();
139        let mut segments = Vec::new();
140        let last = lines.len().saturating_sub(1);
141        for (index, line) in lines.into_iter().enumerate() {
142            segments.push(Segment::new(line.to_string(), self.style.clone()));
143            if index != last {
144                segments.push(Segment::line());
145            }
146        }
147        segments
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use rich::color::ColorSystem;
155
156    #[test]
157    fn renders_a_banner_through_a_console() {
158        let console = Console::builder()
159            .force_terminal(true)
160            .color_system(Some(ColorSystem::Truecolor))
161            .width(80)
162            .no_color(false)
163            .build();
164        let out = console.render_to_string(&Figlet::new("Hi").width(80));
165        // The `standard` font draws `Hi` with these strokes.
166        assert!(out.contains("| | | (_)"), "got:\n{out}");
167        assert!(out.contains("|_| |_|_|"), "got:\n{out}");
168    }
169
170    #[test]
171    fn style_is_applied_to_every_row() {
172        let console = Console::builder()
173            .force_terminal(true)
174            .color_system(Some(ColorSystem::Truecolor))
175            .width(80)
176            .no_color(false)
177            .build();
178        let banner = Figlet::new("Hi")
179            .width(80)
180            .style(Style::parse("bold red").unwrap());
181        let out = console.render_to_string(&banner);
182        assert!(out.contains("\x1b[1;31m"), "expected bold red, got:\n{out}");
183    }
184
185    #[test]
186    fn to_text_matches_the_renderable() {
187        let banner = Figlet::new("Hi");
188        assert!(banner.to_text(80).starts_with(" _   _ _ \n"));
189    }
190}