Skip to main content

rich/
console.rs

1//! The Console — the high-level rendering entry point.
2//!
3//! Port of upstream `rich/console.py` (core subset): terminal / color-system /
4//! width detection, markup + highlighter application, and writing styled output.
5//! Layout options, capture, export, and paging land in the Console-completeness
6//! issue.
7
8use std::io::{IsTerminal, Write};
9
10use crate::color::ColorSystem;
11use crate::protocol::{Highlighter, Renderable};
12use crate::segment::Segment;
13use crate::style::Style;
14use crate::text::Text;
15use crate::theme::Theme;
16
17const DEFAULT_WIDTH: usize = 80;
18const DEFAULT_HEIGHT: usize = 25;
19
20/// A source of the current time in seconds. Upstream's `GetTimeCallable`,
21/// shared by [`Console::get_time`] and [`Progress`](crate::progress::Progress).
22pub type GetTime = std::sync::Arc<dyn Fn() -> f64 + Send + Sync>;
23
24/// Seconds on a monotonic clock: upstream's default `time.monotonic`. Every
25/// default clock in the crate reads this one origin, so times taken from a
26/// console and from a progress display are comparable.
27pub(crate) fn monotonic() -> f64 {
28    static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
29    START
30        .get_or_init(std::time::Instant::now)
31        .elapsed()
32        .as_secs_f64()
33}
34
35/// Horizontal justification of a renderable within its width.
36/// Mirrors `rich.console.JustifyMethod`.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum Justify {
39    /// Renderable-defined default (usually left, no padding).
40    #[default]
41    Default,
42    Left,
43    Center,
44    Right,
45    Full,
46}
47
48/// What to do with text that is wider than the space available.
49/// Mirrors `rich.console.OverflowMethod`.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum Overflow {
52    /// Break over-long words across lines. Upstream's `DEFAULT_OVERFLOW`.
53    #[default]
54    Fold,
55    /// Cut the line off at the width.
56    Crop,
57    /// Cut the line off one cell early and mark it with `…`.
58    Ellipsis,
59    /// Leave over-long lines intact, and do not wrap.
60    Ignore,
61}
62
63/// The options passed to a [`Renderable`] describing the space it must fit into.
64///
65/// Port of the core of `rich.console.ConsoleOptions`. Only the fields needed by
66/// the currently-ported renderables are present; more are added as widgets land.
67#[derive(Debug, Clone)]
68pub struct ConsoleOptions {
69    pub min_width: usize,
70    pub max_width: usize,
71    pub height: Option<usize>,
72    pub justify: Justify,
73    /// Overflow method to impose on renderables, or `None` to let each pick its
74    /// own. Mirrors `ConsoleOptions.overflow`.
75    pub overflow: Option<Overflow>,
76    /// Disable wrapping, or `None` to let each renderable pick. Mirrors
77    /// `ConsoleOptions.no_wrap`.
78    pub no_wrap: Option<bool>,
79}
80
81impl ConsoleOptions {
82    /// Return a copy with `max_width` (and a clamped `min_width`) updated.
83    /// Port of `ConsoleOptions.update_width`.
84    pub fn update_width(&self, width: usize) -> ConsoleOptions {
85        // Copy-then-overwrite rather than a fresh literal, so fields added later
86        // are carried through instead of being silently reset to a default.
87        let mut options = self.clone();
88        options.min_width = width;
89        options.max_width = width;
90        options
91    }
92
93    /// Return a copy with both width and height pinned. Port of
94    /// `ConsoleOptions.update_dimensions`.
95    pub fn update_dimensions(&self, width: usize, height: usize) -> ConsoleOptions {
96        let mut options = self.update_width(width);
97        options.height = Some(height);
98        options
99    }
100}
101
102/// The high-level interface for rendering to a terminal. Mirrors
103/// `rich.console.Console`.
104pub struct Console {
105    render_environment: Option<std::sync::Arc<dyn crate::protocol::RenderEnvironment>>,
106    color_system: Option<ColorSystem>,
107    width: usize,
108    height: usize,
109    is_terminal: bool,
110    no_color: bool,
111    /// Upstream's `Console.get_time`: the clock animations read.
112    get_time: GetTime,
113    emoji: bool,
114    highlight: bool,
115    legacy_windows: bool,
116    safe_box: bool,
117    ascii_only: bool,
118    /// Upstream's `ThemeStack`: the builder's theme at the bottom, pushed
119    /// themes above it. Never empty; styles resolve against the top entry.
120    theme_stack: Vec<Theme>,
121    base_style: Style,
122    highlighters: Vec<Box<dyn Highlighter + Send>>,
123    /// While capturing, print paths append their segments here instead of
124    /// writing to stdout. Mirrors `Console._record_buffer` under `capture()`.
125    record_buffer: std::cell::RefCell<Vec<Segment>>,
126    capturing: std::cell::Cell<bool>,
127}
128
129/// A theme in use on a [`Console`] until this guard drops. Returned by
130/// [`Console::use_theme`]; upstream's `ThemeContext`.
131pub struct ThemeContext<'a> {
132    console: &'a mut Console,
133}
134
135impl std::ops::Deref for ThemeContext<'_> {
136    type Target = Console;
137
138    fn deref(&self) -> &Console {
139        self.console
140    }
141}
142
143impl std::ops::DerefMut for ThemeContext<'_> {
144    fn deref_mut(&mut self) -> &mut Console {
145        self.console
146    }
147}
148
149impl Drop for ThemeContext<'_> {
150    fn drop(&mut self) {
151        // Upstream's `__exit__` pops unconditionally. This only fails if the
152        // caller already popped back down to the base through the guard.
153        let _ = self.console.pop_theme();
154    }
155}
156
157impl Default for Console {
158    fn default() -> Self {
159        Console::new()
160    }
161}
162
163impl Console {
164    /// Auto-detect terminal capabilities from the environment.
165    pub fn new() -> Self {
166        ConsoleBuilder::new().build()
167    }
168
169    /// Start configuring a console explicitly (used by tests and `rich-ext`).
170    pub fn builder() -> ConsoleBuilder {
171        ConsoleBuilder::new()
172    }
173
174    /// The active color system, or `None` when styles are not rendered at
175    /// all. Port of `Console.color_system`.
176    ///
177    /// Like upstream, this is independent of [`no_color`](Self::no_color):
178    /// no-colour mode keeps the colour system and strips only the colours at
179    /// output time, so bold, italic, underline and the like still render.
180    /// Callers asking "will colour reach the terminal?" must check both.
181    pub fn color_system(&self) -> Option<ColorSystem> {
182        self.color_system
183    }
184
185    /// Whether colour output is disabled. Port of `Console.no_color`: set by
186    /// the builder, or by a non-empty `NO_COLOR` environment variable.
187    pub fn no_color(&self) -> bool {
188        self.no_color
189    }
190
191    /// The current time in seconds from this console's clock. Port of
192    /// `Console.get_time` (default `time.monotonic`); override it with
193    /// [`ConsoleBuilder::get_time`] for deterministic animation.
194    pub fn get_time(&self) -> f64 {
195        (self.get_time)()
196    }
197
198    /// The detected (or configured) width in cells.
199    pub fn width(&self) -> usize {
200        self.width
201    }
202
203    /// The detected (or configured) height in rows. Used by height-aware
204    /// renderables such as [`Layout`](crate::layout::Layout).
205    pub fn height(&self) -> usize {
206        self.height
207    }
208
209    /// Whether output is going to a real terminal.
210    pub fn is_terminal(&self) -> bool {
211        self.is_terminal
212    }
213
214    /// Whether output targets a legacy Windows console (drives box substitution).
215    pub fn legacy_windows(&self) -> bool {
216        self.legacy_windows
217    }
218
219    /// Whether to substitute box glyphs for terminal-safe variants (default on).
220    pub fn safe_box(&self) -> bool {
221        self.safe_box
222    }
223
224    /// Whether the terminal can only render ASCII (forces the `ASCII` box).
225    pub fn ascii_only(&self) -> bool {
226        self.ascii_only
227    }
228
229    /// The active theme: the top of the theme stack.
230    pub fn theme(&self) -> &Theme {
231        self.theme_stack
232            .last()
233            .expect("the theme stack always holds its base theme")
234    }
235
236    /// Resolve a style name (or pass a style through) against this console's
237    /// theme. Port of `Console.get_style`.
238    pub fn get_style(&self, style: &crate::style::StyleType) -> crate::errors::Result<Style> {
239        self.theme().get_style(style)
240    }
241
242    /// Push a theme on to the top of the stack. Port of `Console.push_theme`.
243    ///
244    /// With `inherit` the new top is the current top's styles overridden by
245    /// `theme`'s; without it, the new top is exactly `theme`. Prefer
246    /// [`use_theme`](Self::use_theme), which pops again automatically.
247    pub fn push_theme(&mut self, theme: Theme, inherit: bool) {
248        let top = if inherit {
249            let mut merged = self.theme().clone();
250            merged.extend_from(&theme);
251            merged
252        } else {
253            theme
254        };
255        self.theme_stack.push(top);
256    }
257
258    /// Remove the top theme, restoring the previous one. Port of
259    /// `Console.pop_theme`; popping the base theme is an error
260    /// (upstream's `ThemeStackError("Unable to pop base theme")`).
261    pub fn pop_theme(&mut self) -> crate::errors::Result<()> {
262        if self.theme_stack.len() == 1 {
263            return Err(crate::errors::RichError::ThemeStack(
264                "Unable to pop base theme".to_string(),
265            ));
266        }
267        self.theme_stack.pop();
268        Ok(())
269    }
270
271    /// Use a theme until the returned guard is dropped. Port of
272    /// `Console.use_theme`, Python's context manager as an RAII guard.
273    ///
274    /// The guard dereferences to the console, so print *through the guard*
275    /// while it is alive; dropping it pops the theme, including during a
276    /// panic unwind.
277    ///
278    /// ```
279    /// # use rich::{Console, Theme, Style};
280    /// let mut console = Console::builder().width(20).build();
281    /// let mut theme = Theme::new();
282    /// theme.insert("warning", Style::parse("bold red").unwrap());
283    /// {
284    ///     let themed = console.use_theme(theme);
285    ///     assert!(themed.theme().get("warning").is_some());
286    /// }
287    /// assert!(console.theme().get("warning").is_none());
288    /// ```
289    ///
290    /// Upstream's `use_theme` also takes `inherit`, but its `ThemeContext`
291    /// never passes it on to `push_theme`, so a used theme always inherits
292    /// (verified against rich 15.0.0). This port keeps that behaviour and
293    /// omits the ignored parameter; call [`push_theme`](Self::push_theme) to
294    /// replace the styles outright.
295    pub fn use_theme(&mut self, theme: Theme) -> ThemeContext<'_> {
296        self.push_theme(theme, true);
297        ThemeContext { console: self }
298    }
299
300    /// The whole-output base style.
301    pub fn base_style(&self) -> &Style {
302        &self.base_style
303    }
304
305    /// Register a highlighter. **The core plugin seam** — see docs/PLUGINS.md.
306    /// The highlighter must be `Send` so a [`Console`](Console) can move to a
307    /// background thread (e.g. an auto-refreshing [`Live`](crate::live::Live)).
308    pub fn add_highlighter(&mut self, highlighter: Box<dyn Highlighter + Send>) {
309        self.highlighters.push(highlighter);
310    }
311
312    /// The default render options for this console (full width, no height).
313    pub fn options(&self) -> ConsoleOptions {
314        ConsoleOptions {
315            min_width: 1,
316            max_width: self.width,
317            height: None,
318            justify: Justify::Default,
319            overflow: None,
320            no_wrap: None,
321        }
322    }
323
324    /// Render a value to an ANSI string (no trailing newline). Primarily for
325    /// tests and inline rendering.
326    ///
327    /// When no explicit justify is requested, the width is first shrunk to the
328    /// renderable's measured width (matching upstream's measurement-fit for a
329    /// bare top-level renderable).
330    pub fn render_to_string(&self, renderable: &dyn Renderable) -> String {
331        let segments = self.render_segments(renderable);
332        self.segments_to_string(&segments)
333    }
334
335    /// Render a renderable to segments as `Console.print` does (shared by the
336    /// string and print paths): a printed `Text` goes through upstream's
337    /// `Text.join`, and an extension that opts into measurement-fit is shrunk
338    /// to its measured width when no explicit justify is set.
339    fn render_segments(&self, renderable: &dyn Renderable) -> Vec<Segment> {
340        self.render_segments_with(renderable, &self.options())
341    }
342
343    /// [`render_segments`](Self::render_segments) with explicit render options,
344    /// as upstream's `Console.print(…, justify=, overflow=, no_wrap=)` builds
345    /// them.
346    fn render_segments_with(
347        &self,
348        renderable: &dyn Renderable,
349        options: &ConsoleOptions,
350    ) -> Vec<Segment> {
351        let mut options = options.clone();
352        let joined;
353        let renderable = match renderable.printed_text() {
354            Some(text) => {
355                joined = text;
356                &joined as &dyn Renderable
357            }
358            None => renderable,
359        };
360        if options.justify == Justify::Default && renderable.fit_to_measurement() {
361            let measurement = renderable.measure(self, &options);
362            options.max_width = measurement.maximum.min(options.max_width).max(1);
363        }
364        let segments = renderable.rich_render(self, &options);
365        // `Console.print(crop=True)`: the final backstop against a line running
366        // off the side of the terminal. Renderables that fit are untouched; this
367        // is what gives `Overflow::Ignore` its "wrap nothing, but still don't
368        // corrupt the display" behaviour.
369        Segment::crop_lines(&segments, self.width)
370    }
371
372    /// Write (or, while capturing, record) a rendered segment stream, adding a
373    /// trailing newline. The single sink for every `print*` path.
374    fn emit(&self, segments: Vec<Segment>) {
375        self.emit_end(segments, true);
376    }
377
378    /// [`emit`](Self::emit), with the trailing newline optional: upstream's
379    /// `print(…, end="")` when `newline` is false. Output written straight to
380    /// stdout is flushed in that case, so a prompt shows before input is read.
381    fn emit_end(&self, segments: Vec<Segment>, newline: bool) {
382        if segments.is_empty() {
383            return;
384        }
385        if self.capturing.get() {
386            let mut buffer = self.record_buffer.borrow_mut();
387            buffer.extend(segments);
388            if newline {
389                buffer.push(Segment::line());
390            }
391            return;
392        }
393        let mut output = self.segments_to_string(&segments);
394        if newline {
395            output.push('\n');
396        }
397        let stdout = std::io::stdout();
398        let mut lock = stdout.lock();
399        let _ = write!(lock, "{output}");
400        if !newline {
401            let _ = lock.flush();
402        }
403    }
404
405    /// Display `prompt` and read a line of input from standard input. Port of
406    /// `Console.input`: the prompt is console markup, printed through the
407    /// console with `end=""` so it is captured and exported like any other
408    /// output. `None` means end of input.
409    pub fn input(&self, prompt: &str) -> std::io::Result<Option<String>> {
410        let prompt = self.build_text(prompt);
411        self.input_from(&prompt, &mut crate::prompt::StdinInput)
412    }
413
414    /// [`input`](Self::input) with a renderable prompt (upstream accepts a
415    /// `Text`) and an explicit input source — upstream's `stream=` argument.
416    pub fn input_from(
417        &self,
418        prompt: &dyn Renderable,
419        stream: &mut dyn crate::prompt::InputSource,
420    ) -> std::io::Result<Option<String>> {
421        // `if prompt: self.print(prompt, end="")`.
422        let segments = self.render_segments(prompt);
423        if segments.iter().any(|segment| !segment.text.is_empty()) {
424            self.emit_end(segments, false);
425        }
426        stream.read_line()
427    }
428
429    /// Render a value into a list of lines, each a list of [`Segment`]s.
430    ///
431    /// Port of `Console.render_lines`. When `pad` is true, every line is padded
432    /// (or cropped) to `options.max_width` — this is what container renderables
433    /// such as `Panel`/`Padding` rely on to get uniform-width child rows.
434    pub fn render_lines(
435        &self,
436        renderable: &dyn Renderable,
437        options: &ConsoleOptions,
438        pad: bool,
439    ) -> Vec<Vec<Segment>> {
440        self.render_lines_styled(renderable, options, None, pad)
441    }
442
443    /// [`render_lines`](Self::render_lines) with upstream's `style=` argument:
444    /// the style is applied under every rendered segment and to the padding
445    /// that fills each line, as `Panel` and `Padding` use it.
446    pub fn render_lines_styled(
447        &self,
448        renderable: &dyn Renderable,
449        options: &ConsoleOptions,
450        style: Option<&Style>,
451        pad: bool,
452    ) -> Vec<Vec<Segment>> {
453        let style = style.filter(|style| !style.is_null());
454        // Upstream `Console.render` yields nothing when `max_width < 1`, so a
455        // renderable squeezed to zero width contributes no lines (#449).
456        let mut segments = if options.max_width < 1 {
457            Vec::new()
458        } else {
459            renderable.rich_render(self, options)
460        };
461        if let Some(style) = style {
462            segments = Segment::apply_style(&segments, style);
463        }
464        let mut lines = Segment::split_lines(&segments);
465        // An empty `Text` renders as a lone empty segment: upstream renders it
466        // as its `end` newline, which `split_and_crop_lines` turns into one
467        // blank line (#442).
468        if lines.is_empty()
469            && !segments.is_empty()
470            && segments
471                .iter()
472                .all(|segment| !segment.control && segment.text.is_empty())
473        {
474            lines.push(Vec::new());
475        }
476        let pad_style = Some(style.cloned().unwrap_or_default());
477        if pad {
478            for line in &mut lines {
479                *line = Segment::adjust_line_length(line, options.max_width, pad_style.clone());
480            }
481        }
482        // Honor an explicit height by cropping/padding to exactly that many rows
483        // (matching `Console.render_lines`'s height handling — used by height-
484        // aware containers such as `Panel` inside a `Layout`).
485        if let Some(height) = options.height {
486            lines.truncate(height);
487            while lines.len() < height {
488                lines.push(if pad {
489                    vec![Segment::new(
490                        " ".repeat(options.max_width),
491                        pad_style.clone(),
492                    )]
493                } else {
494                    Vec::new()
495                });
496            }
497        }
498        lines
499    }
500
501    /// Render a value exactly as [`print`](Console::print) would write it,
502    /// returning the string (including the single trailing newline). For tests
503    /// and export.
504    pub fn render_export(&self, renderable: &dyn Renderable) -> String {
505        let segments = self.render_segments(renderable);
506        let mut out = self.segments_to_string(&segments);
507        if !segments.is_empty() {
508            out.push('\n');
509        }
510        out
511    }
512
513    /// Render a value and write it to stdout, followed by a newline.
514    pub fn print(&self, renderable: &dyn Renderable) {
515        let segments = self.render_segments(renderable);
516        self.emit(segments);
517    }
518
519    /// Print with explicit render options, the equivalent of upstream's
520    /// `Console.print(renderable, justify=…, overflow=…, no_wrap=…)`. Start
521    /// from [`options`](Self::options) and set the fields to override. A
522    /// printed `Text` defers to these options, because upstream's `Text.join`
523    /// drops the text's own `justify`, `overflow` and `no_wrap`.
524    pub fn print_with(&self, renderable: &dyn Renderable, options: &ConsoleOptions) {
525        let segments = self.render_segments_with(renderable, options);
526        self.emit(segments);
527    }
528
529    /// Like [`render_export`](Self::render_export), with explicit render
530    /// options as for [`print_with`](Self::print_with).
531    pub fn render_export_with(
532        &self,
533        renderable: &dyn Renderable,
534        options: &ConsoleOptions,
535    ) -> String {
536        let segments = self.render_segments_with(renderable, options);
537        let mut out = self.segments_to_string(&segments);
538        if !segments.is_empty() {
539            out.push('\n');
540        }
541        out
542    }
543
544    /// Write a terminal control sequence to stdout.
545    ///
546    /// Port of `Console.control`. Control codes are only written when output is
547    /// a real terminal (they are meaningless when redirected to a file).
548    pub fn control(&self, control: &crate::control::Control) {
549        if !self.is_terminal {
550            return;
551        }
552        let text = control.as_str();
553        if !text.is_empty() {
554            let stdout = std::io::stdout();
555            let mut lock = stdout.lock();
556            let _ = write!(lock, "{text}");
557        }
558    }
559
560    /// Show or hide the cursor. Port of `Console.show_cursor`.
561    pub fn show_cursor(&self, show: bool) {
562        self.control(&crate::control::Control::show_cursor(show));
563    }
564
565    /// Clear the screen. Port of `Console.clear`.
566    pub fn clear(&self) {
567        self.control(&crate::control::Control::clear());
568    }
569
570    /// Ring the terminal bell. Port of `Console.bell`.
571    pub fn bell(&self) {
572        self.control(&crate::control::Control::bell());
573    }
574
575    /// Capture everything printed inside `f` instead of writing it to stdout,
576    /// returning it as a rendered (ANSI) string.
577    ///
578    /// The Rust analogue of upstream's `with console.capture() as capture:` —
579    /// the closure receives the same console, and captures nest correctly.
580    /// Equivalent to what would have been written to the terminal.
581    pub fn capture(&self, f: impl FnOnce(&Console)) -> String {
582        let segments = self.record(f);
583        self.segments_to_string(&segments)
584    }
585
586    /// Like [`capture`](Self::capture) but with all styles stripped, returning
587    /// plain text. Port of `Console.export_text(styles=False)`.
588    pub fn export_text(&self, f: impl FnOnce(&Console)) -> String {
589        let segments = self.record(f);
590        segments_to_plain(&segments)
591    }
592
593    /// Buffer everything printed inside `f` and display it through the system
594    /// pager. The Rust analogue of upstream's `with console.pager():` block.
595    ///
596    /// Styles are stripped unless `styles` is set, matching
597    /// `Console.pager(styles=False)`. When there's no terminal to page in (piped
598    /// output, `TERM=dumb`) or no pager can be started, the content is written
599    /// straight to stdout.
600    pub fn page(&self, styles: bool, f: impl FnOnce(&Console)) -> std::io::Result<()> {
601        self.page_with(&crate::pager::SystemPager, styles, f)
602    }
603
604    /// Like [`page`](Self::page) but with an explicit [`Pager`](crate::pager::Pager)
605    /// — the seam upstream exposes as `Console.pager(pager=…)`.
606    pub fn page_with(
607        &self,
608        pager: &dyn crate::pager::Pager,
609        styles: bool,
610        f: impl FnOnce(&Console),
611    ) -> std::io::Result<()> {
612        let segments = self.record(f);
613        let content = if styles {
614            self.segments_to_string(&segments)
615        } else {
616            segments_to_plain(&segments)
617        };
618        pager.show(&content)
619    }
620
621    /// Capture output printed inside `f` and export it as a self-contained HTML
622    /// document (inline styles), using the default terminal theme. Port of
623    /// `Console.export_html(inline_styles=True)`.
624    pub fn export_html(&self, f: impl FnOnce(&Console)) -> String {
625        self.export_html_themed(&crate::terminal_theme::DEFAULT_TERMINAL_THEME, f)
626    }
627
628    /// Like [`export_html`](Self::export_html) but with an explicit palette —
629    /// upstream's `export_html(theme=…)`. See [`terminal_theme`] for the
630    /// bundled presets.
631    ///
632    /// [`terminal_theme`]: crate::terminal_theme
633    pub fn export_html_themed(
634        &self,
635        theme: &crate::terminal_theme::TerminalTheme,
636        f: impl FnOnce(&Console),
637    ) -> String {
638        let segments = self.record(f);
639        crate::export::export_html_inline(&segments, theme)
640    }
641
642    /// Like [`export_html`](Self::export_html) but with a generated CSS-class
643    /// stylesheet (`.r1 {…}`) instead of inline styles. Port of upstream's
644    /// default `Console.export_html(inline_styles=False)`.
645    pub fn export_html_classes(&self, f: impl FnOnce(&Console)) -> String {
646        self.export_html_classes_themed(&crate::terminal_theme::DEFAULT_TERMINAL_THEME, f)
647    }
648
649    /// Like [`export_html_classes`](Self::export_html_classes) but with an
650    /// explicit palette — upstream's `export_html(theme=…, inline_styles=False)`.
651    pub fn export_html_classes_themed(
652        &self,
653        theme: &crate::terminal_theme::TerminalTheme,
654        f: impl FnOnce(&Console),
655    ) -> String {
656        let segments = self.record(f);
657        crate::export::export_html_classes(&segments, theme)
658    }
659
660    /// Capture output printed inside `f` and export it as a self-contained SVG
661    /// image of a terminal window, using [`SVG_EXPORT_THEME`]. Port of
662    /// `Console.export_svg`.
663    ///
664    /// `unique_id` prefixes every generated id/class. Upstream's auto-computed
665    /// default hashes Python `repr()` output (not reproducible in Rust), so this
666    /// port takes an explicit id; output is byte-parity with
667    /// `export_svg(title=…, unique_id=…)` (see docs/DIVERGENCES.md #15).
668    ///
669    /// [`SVG_EXPORT_THEME`]: crate::terminal_theme::SVG_EXPORT_THEME
670    pub fn export_svg(&self, title: &str, unique_id: &str, f: impl FnOnce(&Console)) -> String {
671        self.export_svg_themed(
672            &crate::terminal_theme::SVG_EXPORT_THEME,
673            title,
674            unique_id,
675            f,
676        )
677    }
678
679    /// Like [`export_svg`](Self::export_svg) but with an explicit palette —
680    /// upstream's `export_svg(theme=…)`.
681    pub fn export_svg_themed(
682        &self,
683        theme: &crate::terminal_theme::TerminalTheme,
684        title: &str,
685        unique_id: &str,
686        f: impl FnOnce(&Console),
687    ) -> String {
688        let segments = self.record(f);
689        crate::svg::export_svg(&segments, theme, title, unique_id, self.width())
690    }
691
692    /// Record everything `f` prints and hand back the raw segments, without
693    /// writing to the terminal.
694    ///
695    /// This is the seam for producing *several* outputs from one render — the
696    /// terminal bytes and an HTML and an SVG file, say — which is what
697    /// `rich --export-html … --export-svg …` needs. Upstream reaches the same
698    /// place with `Console(record=True)` plus `save_html(clear=False)`; here the
699    /// buffer is returned instead of being held on the console, so the caller
700    /// decides what to do with it and there is no hidden state to clear.
701    ///
702    /// Pair with [`segments_to_string`](Self::segments_to_string) to get the
703    /// terminal form, [`export::export_html_classes`](crate::export::export_html_classes)
704    /// for HTML, and [`svg::export_svg`](crate::svg::export_svg) for SVG.
705    ///
706    /// Rendering twice instead would be wrong, not merely wasteful: a renderable
707    /// reading standard input only yields its content once.
708    pub fn record_output(&self, f: impl FnOnce(&Console)) -> Vec<Segment> {
709        self.record(f)
710    }
711
712    /// Run `f` with output recorded to a fresh buffer, returning the captured
713    /// segments and restoring the previous capture state (so captures nest).
714    fn record(&self, f: impl FnOnce(&Console)) -> Vec<Segment> {
715        let previous = std::mem::take(&mut *self.record_buffer.borrow_mut());
716        let was_capturing = self.capturing.replace(true);
717        f(self);
718        let captured = std::mem::replace(&mut *self.record_buffer.borrow_mut(), previous);
719        self.capturing.set(was_capturing);
720        captured
721    }
722
723    /// Parse `content` as console markup, apply registered highlighters, and
724    /// print it. This is the `console.print("...")` path.
725    pub fn print_str(&self, content: &str) {
726        let text = self.build_text(content);
727        self.print(&text);
728    }
729
730    /// Same as [`Console::print_str`] but returns the ANSI string.
731    pub fn render_str_to_string(&self, content: &str) -> String {
732        let text = self.build_text(content);
733        self.render_to_string(&text)
734    }
735
736    /// Parse `content` as console markup (expanding emoji + applying the active
737    /// highlighters), returning the styled [`Text`] that `print_str` would print.
738    /// Exposed so callers can wrap the markup in another renderable.
739    pub fn build_text(&self, content: &str) -> Text {
740        // Malformed markup falls back to printing the text as-is. Upstream would
741        // raise `MarkupError` instead; use `try_build_text` (or `try_print_str`)
742        // when the markup comes from a user and a mistake should be reported
743        // rather than rendered. See docs/DIVERGENCES.md §2.
744        self.try_build_text(content)
745            .unwrap_or_else(|_| self.decorate(Text::new(self.expand_emoji(content))))
746    }
747
748    /// As [`build_text`](Console::build_text), but returns
749    /// [`RichError::Markup`](crate::errors::RichError::Markup) for malformed
750    /// markup instead of falling back to the raw text — upstream's behaviour.
751    pub fn try_build_text(&self, content: &str) -> crate::errors::Result<Text> {
752        let expanded = self.expand_emoji(content);
753        let markup = Text::from_markup(&expanded)?;
754
755        // The highlighter runs on the *markup-stripped* text and its spans go on
756        // first; the markup spans are appended afterwards. Spans combine in
757        // order, so this is what makes an explicit tag beat the highlighter —
758        // `[green]123[/]` is green, not `repr.number` cyan.
759        //
760        // Upstream reaches the same result a different way: `Console.render_str`
761        // highlights a fresh `Text(str(rich_text))` and then calls
762        // `highlight_text.copy_styles(rich_text)`, whose `_spans.extend` appends
763        // the markup spans last. Decorating the markup `Text` in place — the
764        // obvious reading — inverts the precedence.
765        let mut text = self.decorate(Text::new(markup.plain()));
766        for span in markup.spans() {
767            text.push_span(span.clone());
768        }
769        Ok(text)
770    }
771
772    /// Convert a plain string to [`Text`] the way a `str` renderable is
773    /// converted upstream: emoji codes expand (per the console), console
774    /// markup is parsed, and highlighting runs when `highlight` (or, when
775    /// `None`, the console default) enables it. Port of `Console.render_str`,
776    /// which `Table` cells, `Tree` labels and `Columns` items go through.
777    ///
778    /// Malformed markup falls back to the literal text, as
779    /// [`build_text`](Console::build_text) does (docs/DIVERGENCES.md §2).
780    pub fn render_str(&self, content: &str, highlight: Option<bool>) -> Text {
781        let highlight = highlight.unwrap_or(self.highlight);
782        // `markup.render` returns the (emoji-replaced) string untouched when it
783        // holds no `[`; skip the parser for the common plain cell.
784        let markup = if content.contains('[') {
785            let expanded = self.expand_emoji(content);
786            Text::from_markup(&expanded).unwrap_or_else(|_| Text::new(expanded))
787        } else if content.contains(':') {
788            Text::new(self.expand_emoji(content))
789        } else {
790            Text::new(content)
791        };
792        if !highlight {
793            return markup;
794        }
795        // Highlight the plain text, then append the markup spans, as
796        // `highlight_text.copy_styles(rich_text)` does (see `try_build_text`).
797        let mut text = self.decorate_with_repr(Text::new(markup.plain()));
798        for span in markup.spans() {
799            text.push_span(span.clone());
800        }
801        text
802    }
803
804    /// As [`print_str`](Console::print_str), but reports malformed markup.
805    pub fn try_print_str(&self, content: &str) -> crate::errors::Result<()> {
806        self.print(&self.try_build_text(content)?);
807        Ok(())
808    }
809
810    /// As [`print_justified`](Console::print_justified), but reports malformed
811    /// markup.
812    pub fn try_print_justified(
813        &self,
814        content: &str,
815        justify: Justify,
816    ) -> crate::errors::Result<()> {
817        let text = self.try_build_text(content)?;
818        let mut options = self.options();
819        options.justify = justify;
820        self.emit(text.rich_render(self, &options));
821        Ok(())
822    }
823
824    /// Expand `:emoji:` shortcodes. Runs before markup parsing (matching
825    /// upstream's default `emoji=True`); `:name:` and `[tag]` don't overlap.
826    pub(crate) fn expand_emoji(&self, content: &str) -> String {
827        if self.emoji {
828            crate::emoji::replace(content)
829        } else {
830            content.to_string()
831        }
832    }
833
834    /// Apply the registered highlighters, plus the built-in `ReprHighlighter`
835    /// when `highlight` is on.
836    fn decorate(&self, mut text: Text) -> Text {
837        for highlighter in &self.highlighters {
838            highlighter.highlight(&mut text);
839        }
840        if self.highlight {
841            crate::highlighter::ReprHighlighter::new().highlight(&mut text);
842        }
843        text
844    }
845
846    /// [`decorate`](Self::decorate) for a caller that has already decided to
847    /// highlight (upstream's `highlight=True` override of the console default).
848    fn decorate_with_repr(&self, mut text: Text) -> Text {
849        for highlighter in &self.highlighters {
850            highlighter.highlight(&mut text);
851        }
852        crate::highlighter::ReprHighlighter::new().highlight(&mut text);
853        text
854    }
855
856    /// Parse `content` as markup and print it justified to the console width.
857    /// This is the `console.print("...", justify=...)` path.
858    pub fn print_justified(&self, content: &str, justify: Justify) {
859        let text = self.build_text(content);
860        let mut options = self.options();
861        options.justify = justify;
862        let segments = text.rich_render(self, &options);
863        self.emit(segments);
864    }
865
866    /// Same as [`Console::print_justified`] but returns the ANSI string.
867    ///
868    /// The justify is passed via `options.justify`, which — matching upstream —
869    /// disables the measurement-fit so the text pads to the full width.
870    pub fn render_justified_to_string(&self, content: &str, justify: Justify) -> String {
871        let text = self.build_text(content);
872        let mut options = self.options();
873        options.justify = justify;
874        let segments = text.rich_render(self, &options);
875        self.segments_to_string(&segments)
876    }
877
878    /// Convert rendered segments into a terminal string, applying this console's
879    /// colour system (and honouring `no_color`). Port of `Console._render_buffer`.
880    pub fn segments_to_string(&self, segments: &[Segment]) -> String {
881        let system = self.color_system;
882        // `if self.no_color and color_system: buffer = Segment.remove_color(…)`:
883        // colours go, every other attribute stays.
884        let colorless;
885        let segments = if self.no_color && system.is_some() {
886            colorless = Segment::remove_color(segments);
887            &colorless[..]
888        } else {
889            segments
890        };
891        let mut out = String::new();
892        for segment in segments {
893            // Control codes are meaningless off a terminal — upstream's
894            // `_render_buffer` drops them when `not is_terminal`.
895            if segment.control && !self.is_terminal {
896                continue;
897            }
898            match (&segment.style, system) {
899                (Some(style), Some(sys)) => out.push_str(&style.render(&segment.text, Some(sys))),
900                _ => out.push_str(&segment.text),
901            }
902        }
903        out
904    }
905}
906
907/// Join the visible text of a segment stream, dropping control codes. Port of
908/// `Console.export_text(styles=False)`'s join.
909fn segments_to_plain(segments: &[Segment]) -> String {
910    segments
911        .iter()
912        .filter(|s| !s.control)
913        .map(|s| s.text.as_str())
914        .collect()
915}
916
917impl Renderable for Text {
918    fn printed_text(&self) -> Option<Text> {
919        // `Text("").join([self])`: the text and its spans survive; justify,
920        // overflow and no_wrap come from the blank separator (#446). The base
921        // style does not: `join` takes the separator's (none) and re-applies
922        // this text's as a leading span (`if text.style: append_span(...)`),
923        // so print-level justify padding is left unstyled.
924        let mut text = self.clone();
925        text.clear_layout_options();
926        text.base_style_to_span();
927        Some(text)
928    }
929
930    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
931        // Empty Text still represents a printable blank line; an empty
932        // generator such as Markdown does not. Preserve that distinction.
933        if self.is_empty() {
934            return vec![Segment::new("", None)];
935        }
936        // Wrap to the available width; the effective justify is this text's own
937        // justify, falling back to the console options' justify.
938        let justify = if self.get_justify() != Justify::Default {
939            self.get_justify()
940        } else {
941            options.justify
942        };
943        // Same precedence for overflow and no_wrap: the text's own setting wins,
944        // then the options', then upstream's default. Mirrors the `self.x or
945        // options.x or DEFAULT` chain in `Text.__rich_console__`.
946        let overflow = self
947            .get_overflow()
948            .or(options.overflow)
949            .unwrap_or(Overflow::Fold);
950        let no_wrap = self.get_no_wrap().or(options.no_wrap).unwrap_or(false);
951        self.render_joined_wrapped(
952            console.theme(),
953            console.base_style(),
954            options.max_width,
955            justify,
956            overflow,
957            no_wrap,
958        )
959    }
960
961    fn measure(&self, _console: &Console, options: &ConsoleOptions) -> crate::measure::Measurement {
962        let (minimum, maximum) = self.measurement();
963        crate::measure::Measurement::new(
964            minimum.min(options.max_width),
965            maximum.min(options.max_width),
966        )
967    }
968}
969
970/// Builder for [`Console`], allowing detection to be overridden.
971pub struct ConsoleBuilder {
972    force_terminal: Option<bool>,
973    color_system: Option<ColorSystem>,
974    color_system_set: bool,
975    width: Option<usize>,
976    height: Option<usize>,
977    no_color: Option<bool>,
978    get_time: Option<GetTime>,
979    emoji: Option<bool>,
980    highlight: Option<bool>,
981    legacy_windows: Option<bool>,
982    safe_box: Option<bool>,
983    ascii_only: Option<bool>,
984    theme: Option<Theme>,
985}
986
987impl ConsoleBuilder {
988    fn new() -> Self {
989        ConsoleBuilder {
990            force_terminal: None,
991            color_system: None,
992            color_system_set: false,
993            width: None,
994            height: None,
995            no_color: None,
996            get_time: None,
997            emoji: None,
998            highlight: None,
999            legacy_windows: None,
1000            safe_box: None,
1001            ascii_only: None,
1002            theme: None,
1003        }
1004    }
1005
1006    pub fn force_terminal(mut self, value: bool) -> Self {
1007        self.force_terminal = Some(value);
1008        self
1009    }
1010
1011    /// Force legacy-Windows-console behavior (box substitution). Default off.
1012    pub fn legacy_windows(mut self, value: bool) -> Self {
1013        self.legacy_windows = Some(value);
1014        self
1015    }
1016
1017    /// Enable/disable terminal-safe box substitution (default on).
1018    pub fn safe_box(mut self, value: bool) -> Self {
1019        self.safe_box = Some(value);
1020        self
1021    }
1022
1023    /// Force ASCII-only box rendering (default off). Set for non-UTF-8 terminals.
1024    pub fn ascii_only(mut self, value: bool) -> Self {
1025        self.ascii_only = Some(value);
1026        self
1027    }
1028
1029    /// Force a specific color system (use for reproducible output/tests).
1030    pub fn color_system(mut self, system: Option<ColorSystem>) -> Self {
1031        self.color_system = system;
1032        self.color_system_set = true;
1033        self
1034    }
1035
1036    pub fn width(mut self, width: usize) -> Self {
1037        self.width = Some(width);
1038        self
1039    }
1040
1041    /// Set the console height in rows (used by [`Layout`](crate::layout::Layout)).
1042    pub fn height(mut self, height: usize) -> Self {
1043        self.height = Some(height);
1044        self
1045    }
1046
1047    /// Enable no-colour mode: colours are stripped from output while other
1048    /// attributes (bold, underline, …) still render. Unset, a non-empty
1049    /// `NO_COLOR` environment variable enables it. Port of `no_color=`.
1050    pub fn no_color(mut self, value: bool) -> Self {
1051        self.no_color = Some(value);
1052        self
1053    }
1054
1055    /// Read the current time (seconds) from `clock` instead of the monotonic
1056    /// clock. Port of `Console(get_time=…)`; animations such as
1057    /// [`Spinner`](crate::spinner::Spinner) render the frame for this time.
1058    pub fn get_time(mut self, clock: impl Fn() -> f64 + Send + Sync + 'static) -> Self {
1059        self.get_time = Some(std::sync::Arc::new(clock));
1060        self
1061    }
1062
1063    /// Enable/disable `:emoji:` shortcode replacement (default enabled).
1064    pub fn emoji(mut self, value: bool) -> Self {
1065        self.emoji = Some(value);
1066        self
1067    }
1068
1069    /// Enable/disable automatic repr highlighting. Defaults to **on**, matching
1070    /// upstream `Console(highlight=True)`.
1071    pub fn highlight(mut self, value: bool) -> Self {
1072        self.highlight = Some(value);
1073        self
1074    }
1075
1076    pub fn theme(mut self, theme: Theme) -> Self {
1077        self.theme = Some(theme);
1078        self
1079    }
1080
1081    pub fn build(self) -> Console {
1082        let is_terminal = self
1083            .force_terminal
1084            .unwrap_or_else(|| std::io::stdout().is_terminal());
1085        // Upstream's rule is `environ.get("NO_COLOR", "") != ""`, so an EMPTY
1086        // NO_COLOR does not disable colour — only a non-empty value does. That
1087        // matters because a shell that exports `NO_COLOR=` (a common way to
1088        // clear it) would otherwise still be treated as opting out.
1089        let no_color = self
1090            .no_color
1091            .unwrap_or_else(|| std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty()));
1092        let color_system = if self.color_system_set {
1093            self.color_system
1094        } else if is_terminal && !is_dumb_terminal() {
1095            Some(detect_color_system())
1096        } else {
1097            None
1098        };
1099        let width = self.width.unwrap_or_else(detect_width);
1100        let height = self.height.unwrap_or_else(detect_height);
1101        Console {
1102            render_environment: None,
1103            color_system,
1104            width,
1105            height,
1106            is_terminal,
1107            no_color,
1108            get_time: self
1109                .get_time
1110                .unwrap_or_else(|| std::sync::Arc::new(monotonic)),
1111            emoji: self.emoji.unwrap_or(true),
1112            // Upstream's `Console(highlight=True)` default. Getting this wrong is
1113            // invisible in the fixtures (every one is captured with
1114            // highlight=False) but is the first thing a user sees: numbers,
1115            // paths, booleans and URLs come out plain instead of coloured.
1116            highlight: self.highlight.unwrap_or(true),
1117            legacy_windows: self.legacy_windows.unwrap_or(false),
1118            safe_box: self.safe_box.unwrap_or(true),
1119            ascii_only: self.ascii_only.unwrap_or(false),
1120            theme_stack: vec![self.theme.unwrap_or_else(Theme::default_theme)],
1121            base_style: Style::new(),
1122            highlighters: Vec::new(),
1123            record_buffer: std::cell::RefCell::new(Vec::new()),
1124            capturing: std::cell::Cell::new(false),
1125        }
1126    }
1127}
1128
1129/// Whether `TERM` names a terminal that cannot render styles. Port of
1130/// `Console.is_dumb_terminal` (the caller supplies the `is_terminal` half):
1131/// `_detect_color_system` returns no colour system for one, so its output is
1132/// plain even though it is a terminal.
1133fn is_dumb_terminal() -> bool {
1134    std::env::var("TERM")
1135        .map(|term| matches!(term.to_lowercase().as_str(), "dumb" | "unknown"))
1136        .unwrap_or(false)
1137}
1138
1139/// Detect the terminal color system.
1140///
1141/// `COLORTERM`/`TERM` are the portable signals, but **Windows sets neither**.
1142/// Detecting from them alone meant every Windows console fell back to
1143/// [`ColorSystem::Standard`] — 16 colors — for all output. Measured on a real
1144/// Windows Terminal session: 28 distinct colors in a rendered heat map against
1145/// 140 once truecolor was detected.
1146///
1147/// Upstream `rich` special-cases Windows for the same reason. It reaches the
1148/// platform APIs directly; we ask `anstyle-query`, which avoids hand-written
1149/// `unsafe` FFI for a console handle (see `docs/DIVERGENCES.md`).
1150fn detect_color_system() -> ColorSystem {
1151    if let Some(colorterm) = std::env::var_os("COLORTERM") {
1152        let colorterm = colorterm.to_string_lossy().to_ascii_lowercase();
1153        if colorterm.contains("truecolor") || colorterm.contains("24bit") {
1154            return ColorSystem::Truecolor;
1155        }
1156    }
1157
1158    // Windows. This function is only reached when stdout is a terminal (see
1159    // ConsoleBuilder::build), and every modern Windows console that can be a
1160    // terminal speaks 24-bit color, so report truecolor.
1161    //
1162    // The call below is for its SIDE EFFECT — it turns on
1163    // ENABLE_VIRTUAL_TERMINAL_PROCESSING, which legacy `conhost` needs before
1164    // it honours any escape sequence. Its RETURN VALUE is deliberately ignored:
1165    // it enables VT on stdout *and stderr* and propagates failure with `?`, so
1166    // merely redirecting stderr (`rich ... 2>log`, the most natural CI
1167    // invocation) made it report failure and dropped the whole console to 16
1168    // colors — even though stdout was still a fully capable terminal.
1169    #[cfg(windows)]
1170    {
1171        let _ = anstyle_query::windows::enable_ansi_colors();
1172        ColorSystem::Truecolor
1173    }
1174
1175    // `TERM` is meaningless on Windows and the branch above always returns, so
1176    // gating this keeps either platform free of unreachable code.
1177    #[cfg(not(windows))]
1178    {
1179        if let Some(term) = std::env::var_os("TERM") {
1180            if term.to_string_lossy().contains("256") {
1181                return ColorSystem::EightBit;
1182            }
1183        }
1184        ColorSystem::Standard
1185    }
1186}
1187
1188/// Detect the terminal width: `COLUMNS`, then the real terminal, then a default.
1189fn detect_width() -> usize {
1190    if let Some(columns) = std::env::var_os("COLUMNS") {
1191        if let Ok(value) = columns.to_string_lossy().trim().parse::<usize>() {
1192            if value > 0 {
1193                return value;
1194            }
1195        }
1196    }
1197    if let Some((terminal_size::Width(w), _)) = terminal_size::terminal_size() {
1198        if w > 0 {
1199            return w as usize;
1200        }
1201    }
1202    DEFAULT_WIDTH
1203}
1204
1205/// Detect the terminal height: `LINES`, then the real terminal, then a default.
1206fn detect_height() -> usize {
1207    if let Some(lines) = std::env::var_os("LINES") {
1208        if let Ok(value) = lines.to_string_lossy().trim().parse::<usize>() {
1209            if value > 0 {
1210                return value;
1211            }
1212        }
1213    }
1214    if let Some((_, terminal_size::Height(h))) = terminal_size::terminal_size() {
1215        if h > 0 {
1216            return h as usize;
1217        }
1218    }
1219    DEFAULT_HEIGHT
1220}
1221
1222impl crate::protocol::ConsoleEnvironment for Console {
1223    fn set_render_environment(
1224        &mut self,
1225        value: Option<std::sync::Arc<dyn crate::protocol::RenderEnvironment>>,
1226    ) {
1227        self.render_environment = value;
1228    }
1229    fn render_environment(&self) -> Option<&dyn crate::protocol::RenderEnvironment> {
1230        self.render_environment.as_deref()
1231    }
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236    use super::*;
1237
1238    fn test_console() -> Console {
1239        Console::builder()
1240            .force_terminal(true)
1241            .color_system(Some(ColorSystem::Truecolor))
1242            .width(80)
1243            .no_color(false)
1244            .build()
1245    }
1246
1247    /// The strict path reports malformed markup where the lenient one prints it
1248    /// literally. Both must still agree on markup that is actually valid.
1249    #[test]
1250    fn empty_text_and_empty_renderables_have_distinct_endings() {
1251        let console = Console::builder().force_terminal(false).build();
1252        assert_eq!(console.render_export(&Text::new("")), "\n");
1253        assert_eq!(
1254            console.render_export(&crate::markdown::Markdown::new("")),
1255            ""
1256        );
1257        assert_eq!(console.render_export(&crate::table::Table::new()), "\n");
1258    }
1259
1260    #[test]
1261    fn try_build_text_reports_bad_markup() {
1262        let console = test_console();
1263
1264        let err = console
1265            .try_build_text("[/nope]")
1266            .expect_err("an unmatched closing tag must be an error");
1267        assert!(
1268            matches!(err, crate::errors::RichError::Markup(_)),
1269            "{err:?}"
1270        );
1271        // The lenient path swallows it and prints the source text as-is.
1272        assert_eq!(console.build_text("[/nope]").plain(), "[/nope]");
1273
1274        let strict = console.try_build_text("[bold]hi[/]").expect("valid markup");
1275        assert_eq!(strict.plain(), "hi");
1276        assert_eq!(
1277            strict.spans().len(),
1278            console.build_text("[bold]hi[/]").spans().len()
1279        );
1280    }
1281
1282    /// An unknown tag *name* is not an error — it renders as a no-op, tag
1283    /// consumed. Only genuine syntax errors fail.
1284    ///
1285    /// Verified against real rich 15.0.0: `Console().print("[nope]x[/]")` writes
1286    /// `x`, while `[bold]a[/italic]` raises `MarkupError`. Before names were
1287    /// carried on spans, the port resolved `nope` eagerly, failed, and fell back
1288    /// to printing the markup source literally.
1289    #[test]
1290    fn unknown_tag_names_render_as_no_ops() {
1291        let console = test_console();
1292        let text = console
1293            .try_build_text("[nope]x[/]")
1294            .expect("an unknown tag name is not a syntax error");
1295        assert_eq!(console.render_to_string(&text), "x");
1296        assert_eq!(
1297            console.render_to_string(&console.build_text("[a.b.c]x[/]")),
1298            "x"
1299        );
1300
1301        // A mismatched closing tag is still an error, on both paths.
1302        assert!(console.try_build_text("[bold]a[/italic]").is_err());
1303        assert!(console.try_build_text("[/nope]").is_err());
1304    }
1305
1306    /// Markup styles bind to the theme of the console that renders the text, not
1307    /// the one that parsed it. Verified against real rich 15.0.0.
1308    #[test]
1309    fn markup_styles_bind_at_render_not_at_parse() {
1310        let themed = |definition: &str| {
1311            let mut theme = Theme::default_theme();
1312            theme.insert("accent", Style::parse(definition).unwrap());
1313            Console::builder()
1314                .force_terminal(true)
1315                .color_system(Some(ColorSystem::Truecolor))
1316                .width(80)
1317                .no_color(false)
1318                .theme(theme)
1319                .build()
1320        };
1321        let red = themed("bold red");
1322        let green = themed("underline green");
1323
1324        // Built once, by the red console...
1325        let text = red.build_text("[accent]hi[/]");
1326        assert_eq!(red.render_to_string(&text), "\x1b[1;31mhi\x1b[0m");
1327        // ...and the green console still renders it in green.
1328        assert_eq!(green.render_to_string(&text), "\x1b[4;32mhi\x1b[0m");
1329    }
1330
1331    /// Emoji expansion and the highlighters have to run on both paths, or the
1332    /// strict variant would quietly render differently from the lenient one.
1333    #[test]
1334    fn try_build_text_expands_emoji_like_build_text() {
1335        let console = test_console();
1336        assert_eq!(
1337            console
1338                .try_build_text(":rocket: go")
1339                .expect("valid")
1340                .plain(),
1341            console.build_text(":rocket: go").plain()
1342        );
1343    }
1344
1345    #[test]
1346    fn renders_markup_string() {
1347        let console = test_console();
1348        assert_eq!(
1349            console.render_str_to_string("[bold red]hi[/]"),
1350            "\x1b[1;31mhi\x1b[0m"
1351        );
1352    }
1353
1354    #[test]
1355    fn print_justify_pads_to_width() {
1356        let console = Console::builder()
1357            .force_terminal(true)
1358            .color_system(Some(ColorSystem::Truecolor))
1359            .width(10)
1360            .build();
1361        // Captured from real rich 15.0.0: console.print("hi", justify=...).
1362        assert_eq!(
1363            console.render_justified_to_string("hi", Justify::Left),
1364            "hi        "
1365        );
1366        assert_eq!(
1367            console.render_justified_to_string("hi", Justify::Center),
1368            "    hi    "
1369        );
1370        assert_eq!(
1371            console.render_justified_to_string("hi", Justify::Right),
1372            "        hi"
1373        );
1374    }
1375
1376    #[test]
1377    fn capture_records_ansi_instead_of_stdout() {
1378        let console = Console::builder()
1379            .force_terminal(true)
1380            .color_system(Some(ColorSystem::Truecolor))
1381            .width(20)
1382            .build();
1383        // Captured from real rich 15.0.0 (Console.capture()).
1384        let out = console.capture(|c| c.print_str("[bold red]hi[/] there"));
1385        assert_eq!(out, "\x1b[1;31mhi\x1b[0m there\n");
1386    }
1387
1388    #[test]
1389    fn themed_exports_use_the_given_palette() {
1390        use crate::terminal_theme::{MONOKAI, NIGHT_OWLISH};
1391
1392        let console = Console::builder()
1393            .force_terminal(true)
1394            .color_system(Some(ColorSystem::Truecolor))
1395            .width(20)
1396            .no_color(false)
1397            .build();
1398        let render = |c: &Console| c.print_str("hi");
1399
1400        // Monokai's background is #0c0c0c and Night Owlish's is #ffffff, so the
1401        // chosen theme has to show up in the emitted CSS.
1402        let monokai = console.export_html_themed(&MONOKAI, render);
1403        assert!(
1404            monokai.contains("#0c0c0c"),
1405            "monokai bg missing:\n{monokai}"
1406        );
1407
1408        let owlish = console.export_html_themed(&NIGHT_OWLISH, render);
1409        assert!(owlish.contains("#ffffff"), "owlish bg missing:\n{owlish}");
1410        assert!(!owlish.contains("#0c0c0c"), "leaked monokai into owlish");
1411
1412        // The class form and SVG take a theme too.
1413        let classes = console.export_html_classes_themed(&MONOKAI, render);
1414        assert!(classes.contains("#0c0c0c"), "class-form ignored the theme");
1415        let svg = console.export_svg_themed(&MONOKAI, "t", "id", render);
1416        assert!(svg.contains("#0c0c0c"), "svg ignored the theme");
1417
1418        // The convenience methods keep their documented defaults.
1419        assert!(console.export_html(render).contains("#ffffff"));
1420    }
1421
1422    #[test]
1423    fn page_with_honors_the_styles_flag() {
1424        use std::sync::Mutex;
1425
1426        #[derive(Default)]
1427        struct Recorder(Mutex<String>);
1428        impl crate::pager::Pager for Recorder {
1429            fn show(&self, content: &str) -> std::io::Result<()> {
1430                *self.0.lock().unwrap() = content.to_string();
1431                Ok(())
1432            }
1433        }
1434
1435        let console = Console::builder()
1436            .force_terminal(true)
1437            .color_system(Some(ColorSystem::Truecolor))
1438            .width(20)
1439            .no_color(false)
1440            .build();
1441
1442        // styles = false (upstream's `Console.pager()` default) strips ANSI.
1443        let plain = Recorder::default();
1444        console
1445            .page_with(&plain, false, |c| c.print_str("[bold red]hi[/] there"))
1446            .unwrap();
1447        assert_eq!(plain.0.lock().unwrap().as_str(), "hi there\n");
1448
1449        // styles = true keeps it, matching `Console.pager(styles=True)`.
1450        let styled = Recorder::default();
1451        console
1452            .page_with(&styled, true, |c| c.print_str("[bold red]hi[/] there"))
1453            .unwrap();
1454        assert_eq!(
1455            styled.0.lock().unwrap().as_str(),
1456            "\x1b[1;31mhi\x1b[0m there\n"
1457        );
1458    }
1459
1460    #[test]
1461    fn export_text_strips_styles() {
1462        let console = Console::builder()
1463            .force_terminal(true)
1464            .color_system(Some(ColorSystem::Truecolor))
1465            .width(20)
1466            .build();
1467        // Captured from real rich 15.0.0 (Console.export_text(styles=False)).
1468        let out = console.export_text(|c| c.print_str("[bold red]hi[/] there"));
1469        assert_eq!(out, "hi there\n");
1470    }
1471
1472    #[test]
1473    fn export_html_matches_upstream() {
1474        let console = Console::builder()
1475            .force_terminal(true)
1476            .color_system(Some(ColorSystem::Truecolor))
1477            .width(20)
1478            .no_color(false)
1479            .build();
1480        let html = console.export_html(|c| {
1481            c.print_str("[bold red]hi[/] there");
1482            c.print_str("plain line");
1483        });
1484        // Regenerated from real rich by `scripts/capture_golden.py`, so CI's
1485        // drift check covers exports too. Keep this input in step with the
1486        // matching console in that script.
1487        let expected = include_str!("../tests/golden/export_html.html").replace("\r\n", "\n");
1488        assert_eq!(html, expected);
1489    }
1490
1491    #[test]
1492    fn export_html_classes_matches_upstream() {
1493        let console = Console::builder()
1494            .force_terminal(true)
1495            .color_system(Some(ColorSystem::Truecolor))
1496            .width(20)
1497            .no_color(false)
1498            .build();
1499        let html = console.export_html_classes(|c| c.print_str("[bold red]hi[/] there"));
1500        // As above: regenerated by `scripts/capture_golden.py`. Note this test
1501        // prints ONE line where the inline-styles test prints two.
1502        let expected =
1503            include_str!("../tests/golden/export_html_classes.html").replace("\r\n", "\n");
1504        assert_eq!(html, expected);
1505    }
1506
1507    #[test]
1508    fn capture_matches_direct_render() {
1509        let console = test_console();
1510        let panel = crate::panel::Panel::new(Box::new(Text::new("hi")));
1511        assert_eq!(
1512            console.capture(|c| c.print(&panel)),
1513            console.render_export(&panel)
1514        );
1515    }
1516
1517    #[test]
1518    fn no_color_strips_styles() {
1519        let console = Console::builder()
1520            .force_terminal(true)
1521            .color_system(None)
1522            .build();
1523        assert_eq!(console.render_str_to_string("[bold red]hi[/]"), "hi");
1524    }
1525
1526    #[test]
1527    fn used_theme_applies_through_the_guard_and_pops_on_drop() {
1528        let mut console = Console::builder()
1529            .force_terminal(true)
1530            .color_system(Some(ColorSystem::Truecolor))
1531            .width(20)
1532            .highlight(false)
1533            .build();
1534        let theme = Theme::from_styles([("accent", "bold red")], false).unwrap();
1535        {
1536            let themed = console.use_theme(theme);
1537            let out = themed.capture(|c| c.print_str("[accent]x[/]"));
1538            assert_eq!(out, "\x1b[1;31mx\x1b[0m\n");
1539        }
1540        assert!(console.theme().get("accent").is_none());
1541        assert!(console.pop_theme().is_err(), "the base theme must remain");
1542    }
1543
1544    #[test]
1545    fn used_theme_is_popped_during_a_panic_unwind() {
1546        let mut console = Console::builder().width(20).build();
1547        let theme = Theme::from_styles([("accent", "bold")], false).unwrap();
1548        let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1549            let _themed = console.use_theme(theme);
1550            panic!("render failed");
1551        }));
1552        assert!(unwound.is_err());
1553        assert!(console.theme().get("accent").is_none());
1554    }
1555
1556    #[test]
1557    fn a_printed_text_defers_layout_to_the_print_options() {
1558        // Captured from real rich 15.0.0 (#446, #447): `Text.join` drops the
1559        // text's own overflow and justify; print-level options still apply.
1560        let console = Console::builder()
1561            .force_terminal(true)
1562            .color_system(Some(crate::color::ColorSystem::Truecolor))
1563            .width(6)
1564            .highlight(false)
1565            .build();
1566        let text = Text::new("abcdefghij").overflow(Overflow::Ellipsis);
1567        assert_eq!(console.render_export(&text), "abcdef\nghij\n");
1568        let mut options = console.options();
1569        options.overflow = Some(Overflow::Ellipsis);
1570        assert_eq!(
1571            console.render_export_with(&Text::new("abcdefghij"), &options),
1572            "abcde…\n"
1573        );
1574        let wide = Console::builder()
1575            .force_terminal(true)
1576            .color_system(Some(crate::color::ColorSystem::Truecolor))
1577            .width(20)
1578            .highlight(false)
1579            .build();
1580        let tabbed = Text::new("a\tb").justify(Justify::Right);
1581        assert_eq!(wide.render_export(&tabbed), "a       b\n");
1582    }
1583}