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/// Horizontal justification of a renderable within its width.
21/// Mirrors `rich.console.JustifyMethod`.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum Justify {
24    /// Renderable-defined default (usually left, no padding).
25    #[default]
26    Default,
27    Left,
28    Center,
29    Right,
30    Full,
31}
32
33/// What to do with text that is wider than the space available.
34/// Mirrors `rich.console.OverflowMethod`.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum Overflow {
37    /// Break over-long words across lines. Upstream's `DEFAULT_OVERFLOW`.
38    #[default]
39    Fold,
40    /// Cut the line off at the width.
41    Crop,
42    /// Cut the line off one cell early and mark it with `…`.
43    Ellipsis,
44    /// Leave over-long lines intact, and do not wrap.
45    Ignore,
46}
47
48/// The options passed to a [`Renderable`] describing the space it must fit into.
49///
50/// Port of the core of `rich.console.ConsoleOptions`. Only the fields needed by
51/// the currently-ported renderables are present; more are added as widgets land.
52#[derive(Debug, Clone)]
53pub struct ConsoleOptions {
54    pub min_width: usize,
55    pub max_width: usize,
56    pub height: Option<usize>,
57    pub justify: Justify,
58    /// Overflow method to impose on renderables, or `None` to let each pick its
59    /// own. Mirrors `ConsoleOptions.overflow`.
60    pub overflow: Option<Overflow>,
61    /// Disable wrapping, or `None` to let each renderable pick. Mirrors
62    /// `ConsoleOptions.no_wrap`.
63    pub no_wrap: Option<bool>,
64}
65
66impl ConsoleOptions {
67    /// Return a copy with `max_width` (and a clamped `min_width`) updated.
68    /// Port of `ConsoleOptions.update_width`.
69    pub fn update_width(&self, width: usize) -> ConsoleOptions {
70        // Copy-then-overwrite rather than a fresh literal, so fields added later
71        // are carried through instead of being silently reset to a default.
72        let mut options = self.clone();
73        options.min_width = width;
74        options.max_width = width;
75        options
76    }
77
78    /// Return a copy with both width and height pinned. Port of
79    /// `ConsoleOptions.update_dimensions`.
80    pub fn update_dimensions(&self, width: usize, height: usize) -> ConsoleOptions {
81        let mut options = self.update_width(width);
82        options.height = Some(height);
83        options
84    }
85}
86
87/// The high-level interface for rendering to a terminal. Mirrors
88/// `rich.console.Console`.
89pub struct Console {
90    color_system: Option<ColorSystem>,
91    width: usize,
92    height: usize,
93    is_terminal: bool,
94    no_color: bool,
95    emoji: bool,
96    highlight: bool,
97    legacy_windows: bool,
98    safe_box: bool,
99    ascii_only: bool,
100    theme: Theme,
101    base_style: Style,
102    highlighters: Vec<Box<dyn Highlighter + Send>>,
103    /// While capturing, print paths append their segments here instead of
104    /// writing to stdout. Mirrors `Console._record_buffer` under `capture()`.
105    record_buffer: std::cell::RefCell<Vec<Segment>>,
106    capturing: std::cell::Cell<bool>,
107}
108
109impl Default for Console {
110    fn default() -> Self {
111        Console::new()
112    }
113}
114
115impl Console {
116    /// Auto-detect terminal capabilities from the environment.
117    pub fn new() -> Self {
118        ConsoleBuilder::new().build()
119    }
120
121    /// Start configuring a console explicitly (used by tests and `rich-ext`).
122    pub fn builder() -> ConsoleBuilder {
123        ConsoleBuilder::new()
124    }
125
126    /// The active color system, or `None` when color is disabled.
127    pub fn color_system(&self) -> Option<ColorSystem> {
128        if self.no_color {
129            None
130        } else {
131            self.color_system
132        }
133    }
134
135    /// The detected (or configured) width in cells.
136    pub fn width(&self) -> usize {
137        self.width
138    }
139
140    /// The detected (or configured) height in rows. Used by height-aware
141    /// renderables such as [`Layout`](crate::layout::Layout).
142    pub fn height(&self) -> usize {
143        self.height
144    }
145
146    /// Whether output is going to a real terminal.
147    pub fn is_terminal(&self) -> bool {
148        self.is_terminal
149    }
150
151    /// Whether output targets a legacy Windows console (drives box substitution).
152    pub fn legacy_windows(&self) -> bool {
153        self.legacy_windows
154    }
155
156    /// Whether to substitute box glyphs for terminal-safe variants (default on).
157    pub fn safe_box(&self) -> bool {
158        self.safe_box
159    }
160
161    /// Whether the terminal can only render ASCII (forces the `ASCII` box).
162    pub fn ascii_only(&self) -> bool {
163        self.ascii_only
164    }
165
166    /// The active theme.
167    pub fn theme(&self) -> &Theme {
168        &self.theme
169    }
170
171    /// Resolve a style name (or pass a style through) against this console's
172    /// theme. Port of `Console.get_style`.
173    pub fn get_style(&self, style: &crate::style::StyleType) -> crate::errors::Result<Style> {
174        self.theme.get_style(style)
175    }
176
177    /// The whole-output base style.
178    pub fn base_style(&self) -> &Style {
179        &self.base_style
180    }
181
182    /// Register a highlighter. **The core plugin seam** — see docs/PLUGINS.md.
183    /// The highlighter must be `Send` so a [`Console`](Console) can move to a
184    /// background thread (e.g. an auto-refreshing [`Live`](crate::live::Live)).
185    pub fn add_highlighter(&mut self, highlighter: Box<dyn Highlighter + Send>) {
186        self.highlighters.push(highlighter);
187    }
188
189    /// The default render options for this console (full width, no height).
190    pub fn options(&self) -> ConsoleOptions {
191        ConsoleOptions {
192            min_width: 1,
193            max_width: self.width,
194            height: None,
195            justify: Justify::Default,
196            overflow: None,
197            no_wrap: None,
198        }
199    }
200
201    /// Render a value to an ANSI string (no trailing newline). Primarily for
202    /// tests and inline rendering.
203    ///
204    /// When no explicit justify is requested, the width is first shrunk to the
205    /// renderable's measured width (matching upstream's measurement-fit for a
206    /// bare top-level renderable).
207    pub fn render_to_string(&self, renderable: &dyn Renderable) -> String {
208        let segments = self.render_segments(renderable);
209        self.segments_to_string(&segments)
210    }
211
212    /// Render a renderable to segments, applying top-level measurement-fit when
213    /// no explicit justify is set (shared by the string and print paths).
214    fn render_segments(&self, renderable: &dyn Renderable) -> Vec<Segment> {
215        let mut options = self.options();
216        if options.justify == Justify::Default {
217            let measurement = renderable.measure(self, &options);
218            options.max_width = measurement.maximum.min(options.max_width).max(1);
219        }
220        let segments = renderable.rich_render(self, &options);
221        // `Console.print(crop=True)`: the final backstop against a line running
222        // off the side of the terminal. Renderables that fit are untouched; this
223        // is what gives `Overflow::Ignore` its "wrap nothing, but still don't
224        // corrupt the display" behaviour.
225        Segment::crop_lines(&segments, self.width)
226    }
227
228    /// Write (or, while capturing, record) a rendered segment stream, adding a
229    /// trailing newline. The single sink for every `print*` path.
230    fn emit(&self, segments: Vec<Segment>) {
231        if segments.is_empty() {
232            return;
233        }
234        if self.capturing.get() {
235            let mut buffer = self.record_buffer.borrow_mut();
236            buffer.extend(segments);
237            buffer.push(Segment::line());
238            return;
239        }
240        let mut output = self.segments_to_string(&segments);
241        output.push('\n');
242        let stdout = std::io::stdout();
243        let mut lock = stdout.lock();
244        let _ = write!(lock, "{output}");
245    }
246
247    /// Render a value into a list of lines, each a list of [`Segment`]s.
248    ///
249    /// Port of `Console.render_lines`. When `pad` is true, every line is padded
250    /// (or cropped) to `options.max_width` — this is what container renderables
251    /// such as `Panel`/`Padding` rely on to get uniform-width child rows.
252    pub fn render_lines(
253        &self,
254        renderable: &dyn Renderable,
255        options: &ConsoleOptions,
256        pad: bool,
257    ) -> Vec<Vec<Segment>> {
258        let segments = renderable.rich_render(self, options);
259        let mut lines = Segment::split_lines(&segments);
260        if pad {
261            for line in &mut lines {
262                *line = Segment::adjust_line_length(line, options.max_width, Some(Style::new()));
263            }
264        }
265        // Honor an explicit height by cropping/padding to exactly that many rows
266        // (matching `Console.render_lines`'s height handling — used by height-
267        // aware containers such as `Panel` inside a `Layout`).
268        if let Some(height) = options.height {
269            lines.truncate(height);
270            while lines.len() < height {
271                lines.push(if pad {
272                    vec![Segment::new(
273                        " ".repeat(options.max_width),
274                        Some(Style::new()),
275                    )]
276                } else {
277                    Vec::new()
278                });
279            }
280        }
281        lines
282    }
283
284    /// Render a value exactly as [`print`](Console::print) would write it,
285    /// returning the string (including the single trailing newline). For tests
286    /// and export.
287    pub fn render_export(&self, renderable: &dyn Renderable) -> String {
288        let segments = self.render_segments(renderable);
289        let mut out = self.segments_to_string(&segments);
290        if !segments.is_empty() {
291            out.push('\n');
292        }
293        out
294    }
295
296    /// Render a value and write it to stdout, followed by a newline.
297    pub fn print(&self, renderable: &dyn Renderable) {
298        let segments = self.render_segments(renderable);
299        self.emit(segments);
300    }
301
302    /// Write a terminal control sequence to stdout.
303    ///
304    /// Port of `Console.control`. Control codes are only written when output is
305    /// a real terminal (they are meaningless when redirected to a file).
306    pub fn control(&self, control: &crate::control::Control) {
307        if !self.is_terminal {
308            return;
309        }
310        let text = control.as_str();
311        if !text.is_empty() {
312            let stdout = std::io::stdout();
313            let mut lock = stdout.lock();
314            let _ = write!(lock, "{text}");
315        }
316    }
317
318    /// Show or hide the cursor. Port of `Console.show_cursor`.
319    pub fn show_cursor(&self, show: bool) {
320        self.control(&crate::control::Control::show_cursor(show));
321    }
322
323    /// Clear the screen. Port of `Console.clear`.
324    pub fn clear(&self) {
325        self.control(&crate::control::Control::clear());
326    }
327
328    /// Ring the terminal bell. Port of `Console.bell`.
329    pub fn bell(&self) {
330        self.control(&crate::control::Control::bell());
331    }
332
333    /// Capture everything printed inside `f` instead of writing it to stdout,
334    /// returning it as a rendered (ANSI) string.
335    ///
336    /// The Rust analogue of upstream's `with console.capture() as capture:` —
337    /// the closure receives the same console, and captures nest correctly.
338    /// Equivalent to what would have been written to the terminal.
339    pub fn capture(&self, f: impl FnOnce(&Console)) -> String {
340        let segments = self.record(f);
341        self.segments_to_string(&segments)
342    }
343
344    /// Like [`capture`](Self::capture) but with all styles stripped, returning
345    /// plain text. Port of `Console.export_text(styles=False)`.
346    pub fn export_text(&self, f: impl FnOnce(&Console)) -> String {
347        let segments = self.record(f);
348        segments_to_plain(&segments)
349    }
350
351    /// Buffer everything printed inside `f` and display it through the system
352    /// pager. The Rust analogue of upstream's `with console.pager():` block.
353    ///
354    /// Styles are stripped unless `styles` is set, matching
355    /// `Console.pager(styles=False)`. When there's no terminal to page in (piped
356    /// output, `TERM=dumb`) or no pager can be started, the content is written
357    /// straight to stdout.
358    pub fn page(&self, styles: bool, f: impl FnOnce(&Console)) -> std::io::Result<()> {
359        self.page_with(&crate::pager::SystemPager, styles, f)
360    }
361
362    /// Like [`page`](Self::page) but with an explicit [`Pager`](crate::pager::Pager)
363    /// — the seam upstream exposes as `Console.pager(pager=…)`.
364    pub fn page_with(
365        &self,
366        pager: &dyn crate::pager::Pager,
367        styles: bool,
368        f: impl FnOnce(&Console),
369    ) -> std::io::Result<()> {
370        let segments = self.record(f);
371        let content = if styles {
372            self.segments_to_string(&segments)
373        } else {
374            segments_to_plain(&segments)
375        };
376        pager.show(&content)
377    }
378
379    /// Capture output printed inside `f` and export it as a self-contained HTML
380    /// document (inline styles), using the default terminal theme. Port of
381    /// `Console.export_html(inline_styles=True)`.
382    pub fn export_html(&self, f: impl FnOnce(&Console)) -> String {
383        self.export_html_themed(&crate::terminal_theme::DEFAULT_TERMINAL_THEME, f)
384    }
385
386    /// Like [`export_html`](Self::export_html) but with an explicit palette —
387    /// upstream's `export_html(theme=…)`. See [`terminal_theme`] for the
388    /// bundled presets.
389    ///
390    /// [`terminal_theme`]: crate::terminal_theme
391    pub fn export_html_themed(
392        &self,
393        theme: &crate::terminal_theme::TerminalTheme,
394        f: impl FnOnce(&Console),
395    ) -> String {
396        let segments = self.record(f);
397        crate::export::export_html_inline(&segments, theme)
398    }
399
400    /// Like [`export_html`](Self::export_html) but with a generated CSS-class
401    /// stylesheet (`.r1 {…}`) instead of inline styles. Port of upstream's
402    /// default `Console.export_html(inline_styles=False)`.
403    pub fn export_html_classes(&self, f: impl FnOnce(&Console)) -> String {
404        self.export_html_classes_themed(&crate::terminal_theme::DEFAULT_TERMINAL_THEME, f)
405    }
406
407    /// Like [`export_html_classes`](Self::export_html_classes) but with an
408    /// explicit palette — upstream's `export_html(theme=…, inline_styles=False)`.
409    pub fn export_html_classes_themed(
410        &self,
411        theme: &crate::terminal_theme::TerminalTheme,
412        f: impl FnOnce(&Console),
413    ) -> String {
414        let segments = self.record(f);
415        crate::export::export_html_classes(&segments, theme)
416    }
417
418    /// Capture output printed inside `f` and export it as a self-contained SVG
419    /// image of a terminal window, using [`SVG_EXPORT_THEME`]. Port of
420    /// `Console.export_svg`.
421    ///
422    /// `unique_id` prefixes every generated id/class. Upstream's auto-computed
423    /// default hashes Python `repr()` output (not reproducible in Rust), so this
424    /// port takes an explicit id; output is byte-parity with
425    /// `export_svg(title=…, unique_id=…)` (see docs/DIVERGENCES.md #15).
426    ///
427    /// [`SVG_EXPORT_THEME`]: crate::terminal_theme::SVG_EXPORT_THEME
428    pub fn export_svg(&self, title: &str, unique_id: &str, f: impl FnOnce(&Console)) -> String {
429        self.export_svg_themed(
430            &crate::terminal_theme::SVG_EXPORT_THEME,
431            title,
432            unique_id,
433            f,
434        )
435    }
436
437    /// Like [`export_svg`](Self::export_svg) but with an explicit palette —
438    /// upstream's `export_svg(theme=…)`.
439    pub fn export_svg_themed(
440        &self,
441        theme: &crate::terminal_theme::TerminalTheme,
442        title: &str,
443        unique_id: &str,
444        f: impl FnOnce(&Console),
445    ) -> String {
446        let segments = self.record(f);
447        crate::svg::export_svg(&segments, theme, title, unique_id, self.width())
448    }
449
450    /// Record everything `f` prints and hand back the raw segments, without
451    /// writing to the terminal.
452    ///
453    /// This is the seam for producing *several* outputs from one render — the
454    /// terminal bytes and an HTML and an SVG file, say — which is what
455    /// `rich --export-html … --export-svg …` needs. Upstream reaches the same
456    /// place with `Console(record=True)` plus `save_html(clear=False)`; here the
457    /// buffer is returned instead of being held on the console, so the caller
458    /// decides what to do with it and there is no hidden state to clear.
459    ///
460    /// Pair with [`segments_to_string`](Self::segments_to_string) to get the
461    /// terminal form, [`export::export_html_classes`](crate::export::export_html_classes)
462    /// for HTML, and [`svg::export_svg`](crate::svg::export_svg) for SVG.
463    ///
464    /// Rendering twice instead would be wrong, not merely wasteful: a renderable
465    /// reading standard input only yields its content once.
466    pub fn record_output(&self, f: impl FnOnce(&Console)) -> Vec<Segment> {
467        self.record(f)
468    }
469
470    /// Run `f` with output recorded to a fresh buffer, returning the captured
471    /// segments and restoring the previous capture state (so captures nest).
472    fn record(&self, f: impl FnOnce(&Console)) -> Vec<Segment> {
473        let previous = std::mem::take(&mut *self.record_buffer.borrow_mut());
474        let was_capturing = self.capturing.replace(true);
475        f(self);
476        let captured = std::mem::replace(&mut *self.record_buffer.borrow_mut(), previous);
477        self.capturing.set(was_capturing);
478        captured
479    }
480
481    /// Parse `content` as console markup, apply registered highlighters, and
482    /// print it. This is the `console.print("...")` path.
483    pub fn print_str(&self, content: &str) {
484        let text = self.build_text(content);
485        self.print(&text);
486    }
487
488    /// Same as [`Console::print_str`] but returns the ANSI string.
489    pub fn render_str_to_string(&self, content: &str) -> String {
490        let text = self.build_text(content);
491        self.render_to_string(&text)
492    }
493
494    /// Parse `content` as console markup (expanding emoji + applying the active
495    /// highlighters), returning the styled [`Text`] that `print_str` would print.
496    /// Exposed so callers can wrap the markup in another renderable.
497    pub fn build_text(&self, content: &str) -> Text {
498        // Malformed markup falls back to printing the text as-is. Upstream would
499        // raise `MarkupError` instead; use `try_build_text` (or `try_print_str`)
500        // when the markup comes from a user and a mistake should be reported
501        // rather than rendered. See docs/DIVERGENCES.md §2.
502        self.try_build_text(content)
503            .unwrap_or_else(|_| self.decorate(Text::new(self.expand_emoji(content))))
504    }
505
506    /// As [`build_text`](Console::build_text), but returns
507    /// [`RichError::Markup`](crate::errors::RichError::Markup) for malformed
508    /// markup instead of falling back to the raw text — upstream's behaviour.
509    pub fn try_build_text(&self, content: &str) -> crate::errors::Result<Text> {
510        let expanded = self.expand_emoji(content);
511        let markup = Text::from_markup(&expanded)?;
512
513        // The highlighter runs on the *markup-stripped* text and its spans go on
514        // first; the markup spans are appended afterwards. Spans combine in
515        // order, so this is what makes an explicit tag beat the highlighter —
516        // `[green]123[/]` is green, not `repr.number` cyan.
517        //
518        // Upstream reaches the same result a different way: `Console.render_str`
519        // highlights a fresh `Text(str(rich_text))` and then calls
520        // `highlight_text.copy_styles(rich_text)`, whose `_spans.extend` appends
521        // the markup spans last. Decorating the markup `Text` in place — the
522        // obvious reading — inverts the precedence.
523        let mut text = self.decorate(Text::new(markup.plain()));
524        for span in markup.spans() {
525            text.push_span(span.clone());
526        }
527        Ok(text)
528    }
529
530    /// As [`print_str`](Console::print_str), but reports malformed markup.
531    pub fn try_print_str(&self, content: &str) -> crate::errors::Result<()> {
532        self.print(&self.try_build_text(content)?);
533        Ok(())
534    }
535
536    /// As [`print_justified`](Console::print_justified), but reports malformed
537    /// markup.
538    pub fn try_print_justified(
539        &self,
540        content: &str,
541        justify: Justify,
542    ) -> crate::errors::Result<()> {
543        let text = self.try_build_text(content)?;
544        let mut options = self.options();
545        options.justify = justify;
546        self.emit(text.rich_render(self, &options));
547        Ok(())
548    }
549
550    /// Expand `:emoji:` shortcodes. Runs before markup parsing (matching
551    /// upstream's default `emoji=True`); `:name:` and `[tag]` don't overlap.
552    pub(crate) fn expand_emoji(&self, content: &str) -> String {
553        if self.emoji {
554            crate::emoji::replace(content)
555        } else {
556            content.to_string()
557        }
558    }
559
560    /// Apply the registered highlighters, plus the built-in `ReprHighlighter`
561    /// when `highlight` is on.
562    fn decorate(&self, mut text: Text) -> Text {
563        for highlighter in &self.highlighters {
564            highlighter.highlight(&mut text);
565        }
566        if self.highlight {
567            crate::highlighter::ReprHighlighter::new().highlight(&mut text);
568        }
569        text
570    }
571
572    /// Parse `content` as markup and print it justified to the console width.
573    /// This is the `console.print("...", justify=...)` path.
574    pub fn print_justified(&self, content: &str, justify: Justify) {
575        let text = self.build_text(content);
576        let mut options = self.options();
577        options.justify = justify;
578        let segments = text.rich_render(self, &options);
579        self.emit(segments);
580    }
581
582    /// Same as [`Console::print_justified`] but returns the ANSI string.
583    ///
584    /// The justify is passed via `options.justify`, which — matching upstream —
585    /// disables the measurement-fit so the text pads to the full width.
586    pub fn render_justified_to_string(&self, content: &str, justify: Justify) -> String {
587        let text = self.build_text(content);
588        let mut options = self.options();
589        options.justify = justify;
590        let segments = text.rich_render(self, &options);
591        self.segments_to_string(&segments)
592    }
593
594    /// Convert rendered segments into a terminal string, applying this console's
595    /// colour system (and honouring `no_color`).
596    pub fn segments_to_string(&self, segments: &[Segment]) -> String {
597        let system = self.color_system();
598        let mut out = String::new();
599        for segment in segments {
600            // Control codes are meaningless off a terminal — upstream's
601            // `_render_buffer` drops them when `not is_terminal`.
602            if segment.control && !self.is_terminal {
603                continue;
604            }
605            match (&segment.style, system) {
606                (Some(style), Some(sys)) => out.push_str(&style.render(&segment.text, Some(sys))),
607                _ => out.push_str(&segment.text),
608            }
609        }
610        out
611    }
612}
613
614/// Join the visible text of a segment stream, dropping control codes. Port of
615/// `Console.export_text(styles=False)`'s join.
616fn segments_to_plain(segments: &[Segment]) -> String {
617    segments
618        .iter()
619        .filter(|s| !s.control)
620        .map(|s| s.text.as_str())
621        .collect()
622}
623
624impl Renderable for Text {
625    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
626        // Empty Text still represents a printable blank line; an empty
627        // generator such as Markdown does not. Preserve that distinction.
628        if self.is_empty() {
629            return vec![Segment::new("", None)];
630        }
631        // Wrap to the available width; the effective justify is this text's own
632        // justify, falling back to the console options' justify.
633        let justify = if self.get_justify() != Justify::Default {
634            self.get_justify()
635        } else {
636            options.justify
637        };
638        // Same precedence for overflow and no_wrap: the text's own setting wins,
639        // then the options', then upstream's default. Mirrors the `self.x or
640        // options.x or DEFAULT` chain in `Text.__rich_console__`.
641        let overflow = self
642            .get_overflow()
643            .or(options.overflow)
644            .unwrap_or(Overflow::Fold);
645        let no_wrap = self.get_no_wrap().or(options.no_wrap).unwrap_or(false);
646        self.render_joined_wrapped(
647            console.theme(),
648            console.base_style(),
649            options.max_width,
650            justify,
651            overflow,
652            no_wrap,
653        )
654    }
655
656    fn measure(&self, _console: &Console, options: &ConsoleOptions) -> crate::measure::Measurement {
657        let (minimum, maximum) = self.measurement();
658        crate::measure::Measurement::new(
659            minimum.min(options.max_width),
660            maximum.min(options.max_width),
661        )
662    }
663}
664
665/// Builder for [`Console`], allowing detection to be overridden.
666pub struct ConsoleBuilder {
667    force_terminal: Option<bool>,
668    color_system: Option<ColorSystem>,
669    color_system_set: bool,
670    width: Option<usize>,
671    height: Option<usize>,
672    no_color: Option<bool>,
673    emoji: Option<bool>,
674    highlight: Option<bool>,
675    legacy_windows: Option<bool>,
676    safe_box: Option<bool>,
677    ascii_only: Option<bool>,
678    theme: Option<Theme>,
679}
680
681impl ConsoleBuilder {
682    fn new() -> Self {
683        ConsoleBuilder {
684            force_terminal: None,
685            color_system: None,
686            color_system_set: false,
687            width: None,
688            height: None,
689            no_color: None,
690            emoji: None,
691            highlight: None,
692            legacy_windows: None,
693            safe_box: None,
694            ascii_only: None,
695            theme: None,
696        }
697    }
698
699    pub fn force_terminal(mut self, value: bool) -> Self {
700        self.force_terminal = Some(value);
701        self
702    }
703
704    /// Force legacy-Windows-console behavior (box substitution). Default off.
705    pub fn legacy_windows(mut self, value: bool) -> Self {
706        self.legacy_windows = Some(value);
707        self
708    }
709
710    /// Enable/disable terminal-safe box substitution (default on).
711    pub fn safe_box(mut self, value: bool) -> Self {
712        self.safe_box = Some(value);
713        self
714    }
715
716    /// Force ASCII-only box rendering (default off). Set for non-UTF-8 terminals.
717    pub fn ascii_only(mut self, value: bool) -> Self {
718        self.ascii_only = Some(value);
719        self
720    }
721
722    /// Force a specific color system (use for reproducible output/tests).
723    pub fn color_system(mut self, system: Option<ColorSystem>) -> Self {
724        self.color_system = system;
725        self.color_system_set = true;
726        self
727    }
728
729    pub fn width(mut self, width: usize) -> Self {
730        self.width = Some(width);
731        self
732    }
733
734    /// Set the console height in rows (used by [`Layout`](crate::layout::Layout)).
735    pub fn height(mut self, height: usize) -> Self {
736        self.height = Some(height);
737        self
738    }
739
740    pub fn no_color(mut self, value: bool) -> Self {
741        self.no_color = Some(value);
742        self
743    }
744
745    /// Enable/disable `:emoji:` shortcode replacement (default enabled).
746    pub fn emoji(mut self, value: bool) -> Self {
747        self.emoji = Some(value);
748        self
749    }
750
751    /// Enable/disable automatic repr highlighting. Defaults to **on**, matching
752    /// upstream `Console(highlight=True)`.
753    pub fn highlight(mut self, value: bool) -> Self {
754        self.highlight = Some(value);
755        self
756    }
757
758    pub fn theme(mut self, theme: Theme) -> Self {
759        self.theme = Some(theme);
760        self
761    }
762
763    pub fn build(self) -> Console {
764        let is_terminal = self
765            .force_terminal
766            .unwrap_or_else(|| std::io::stdout().is_terminal());
767        // Upstream's rule is `environ.get("NO_COLOR", "") != ""`, so an EMPTY
768        // NO_COLOR does not disable colour — only a non-empty value does. That
769        // matters because a shell that exports `NO_COLOR=` (a common way to
770        // clear it) would otherwise still be treated as opting out.
771        let no_color = self
772            .no_color
773            .unwrap_or_else(|| std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty()));
774        let color_system = if self.color_system_set {
775            self.color_system
776        } else if is_terminal {
777            Some(detect_color_system())
778        } else {
779            None
780        };
781        let width = self.width.unwrap_or_else(detect_width);
782        let height = self.height.unwrap_or_else(detect_height);
783        Console {
784            color_system,
785            width,
786            height,
787            is_terminal,
788            no_color,
789            emoji: self.emoji.unwrap_or(true),
790            // Upstream's `Console(highlight=True)` default. Getting this wrong is
791            // invisible in the fixtures (every one is captured with
792            // highlight=False) but is the first thing a user sees: numbers,
793            // paths, booleans and URLs come out plain instead of coloured.
794            highlight: self.highlight.unwrap_or(true),
795            legacy_windows: self.legacy_windows.unwrap_or(false),
796            safe_box: self.safe_box.unwrap_or(true),
797            ascii_only: self.ascii_only.unwrap_or(false),
798            theme: self.theme.unwrap_or_else(Theme::default_theme),
799            base_style: Style::new(),
800            highlighters: Vec::new(),
801            record_buffer: std::cell::RefCell::new(Vec::new()),
802            capturing: std::cell::Cell::new(false),
803        }
804    }
805}
806
807/// Detect the terminal color system.
808///
809/// `COLORTERM`/`TERM` are the portable signals, but **Windows sets neither**.
810/// Detecting from them alone meant every Windows console fell back to
811/// [`ColorSystem::Standard`] — 16 colors — for all output. Measured on a real
812/// Windows Terminal session: 28 distinct colors in a rendered heat map against
813/// 140 once truecolor was detected.
814///
815/// Upstream `rich` special-cases Windows for the same reason. It reaches the
816/// platform APIs directly; we ask `anstyle-query`, which avoids hand-written
817/// `unsafe` FFI for a console handle (see `docs/DIVERGENCES.md`).
818fn detect_color_system() -> ColorSystem {
819    if let Some(colorterm) = std::env::var_os("COLORTERM") {
820        let colorterm = colorterm.to_string_lossy().to_ascii_lowercase();
821        if colorterm.contains("truecolor") || colorterm.contains("24bit") {
822            return ColorSystem::Truecolor;
823        }
824    }
825
826    // Windows. This function is only reached when stdout is a terminal (see
827    // ConsoleBuilder::build), and every modern Windows console that can be a
828    // terminal speaks 24-bit color, so report truecolor.
829    //
830    // The call below is for its SIDE EFFECT — it turns on
831    // ENABLE_VIRTUAL_TERMINAL_PROCESSING, which legacy `conhost` needs before
832    // it honours any escape sequence. Its RETURN VALUE is deliberately ignored:
833    // it enables VT on stdout *and stderr* and propagates failure with `?`, so
834    // merely redirecting stderr (`rich ... 2>log`, the most natural CI
835    // invocation) made it report failure and dropped the whole console to 16
836    // colors — even though stdout was still a fully capable terminal.
837    #[cfg(windows)]
838    {
839        let _ = anstyle_query::windows::enable_ansi_colors();
840        ColorSystem::Truecolor
841    }
842
843    // `TERM` is meaningless on Windows and the branch above always returns, so
844    // gating this keeps either platform free of unreachable code.
845    #[cfg(not(windows))]
846    {
847        if let Some(term) = std::env::var_os("TERM") {
848            if term.to_string_lossy().contains("256") {
849                return ColorSystem::EightBit;
850            }
851        }
852        ColorSystem::Standard
853    }
854}
855
856/// Detect the terminal width: `COLUMNS`, then the real terminal, then a default.
857fn detect_width() -> usize {
858    if let Some(columns) = std::env::var_os("COLUMNS") {
859        if let Ok(value) = columns.to_string_lossy().trim().parse::<usize>() {
860            if value > 0 {
861                return value;
862            }
863        }
864    }
865    if let Some((terminal_size::Width(w), _)) = terminal_size::terminal_size() {
866        if w > 0 {
867            return w as usize;
868        }
869    }
870    DEFAULT_WIDTH
871}
872
873/// Detect the terminal height: `LINES`, then the real terminal, then a default.
874fn detect_height() -> usize {
875    if let Some(lines) = std::env::var_os("LINES") {
876        if let Ok(value) = lines.to_string_lossy().trim().parse::<usize>() {
877            if value > 0 {
878                return value;
879            }
880        }
881    }
882    if let Some((_, terminal_size::Height(h))) = terminal_size::terminal_size() {
883        if h > 0 {
884            return h as usize;
885        }
886    }
887    DEFAULT_HEIGHT
888}
889
890#[cfg(test)]
891mod tests {
892    use super::*;
893
894    fn test_console() -> Console {
895        Console::builder()
896            .force_terminal(true)
897            .color_system(Some(ColorSystem::Truecolor))
898            .width(80)
899            .no_color(false)
900            .build()
901    }
902
903    /// The strict path reports malformed markup where the lenient one prints it
904    /// literally. Both must still agree on markup that is actually valid.
905    #[test]
906    fn empty_text_and_empty_renderables_have_distinct_endings() {
907        let console = Console::builder().force_terminal(false).build();
908        assert_eq!(console.render_export(&Text::new("")), "\n");
909        assert_eq!(
910            console.render_export(&crate::markdown::Markdown::new("")),
911            ""
912        );
913        assert_eq!(console.render_export(&crate::table::Table::new()), "\n");
914    }
915
916    #[test]
917    fn try_build_text_reports_bad_markup() {
918        let console = test_console();
919
920        let err = console
921            .try_build_text("[/nope]")
922            .expect_err("an unmatched closing tag must be an error");
923        assert!(
924            matches!(err, crate::errors::RichError::Markup(_)),
925            "{err:?}"
926        );
927        // The lenient path swallows it and prints the source text as-is.
928        assert_eq!(console.build_text("[/nope]").plain(), "[/nope]");
929
930        let strict = console.try_build_text("[bold]hi[/]").expect("valid markup");
931        assert_eq!(strict.plain(), "hi");
932        assert_eq!(
933            strict.spans().len(),
934            console.build_text("[bold]hi[/]").spans().len()
935        );
936    }
937
938    /// An unknown tag *name* is not an error — it renders as a no-op, tag
939    /// consumed. Only genuine syntax errors fail.
940    ///
941    /// Verified against real rich 15.0.0: `Console().print("[nope]x[/]")` writes
942    /// `x`, while `[bold]a[/italic]` raises `MarkupError`. Before names were
943    /// carried on spans, the port resolved `nope` eagerly, failed, and fell back
944    /// to printing the markup source literally.
945    #[test]
946    fn unknown_tag_names_render_as_no_ops() {
947        let console = test_console();
948        let text = console
949            .try_build_text("[nope]x[/]")
950            .expect("an unknown tag name is not a syntax error");
951        assert_eq!(console.render_to_string(&text), "x");
952        assert_eq!(
953            console.render_to_string(&console.build_text("[a.b.c]x[/]")),
954            "x"
955        );
956
957        // A mismatched closing tag is still an error, on both paths.
958        assert!(console.try_build_text("[bold]a[/italic]").is_err());
959        assert!(console.try_build_text("[/nope]").is_err());
960    }
961
962    /// Markup styles bind to the theme of the console that renders the text, not
963    /// the one that parsed it. Verified against real rich 15.0.0.
964    #[test]
965    fn markup_styles_bind_at_render_not_at_parse() {
966        let themed = |definition: &str| {
967            let mut theme = Theme::default_theme();
968            theme.insert("accent", Style::parse(definition).unwrap());
969            Console::builder()
970                .force_terminal(true)
971                .color_system(Some(ColorSystem::Truecolor))
972                .width(80)
973                .no_color(false)
974                .theme(theme)
975                .build()
976        };
977        let red = themed("bold red");
978        let green = themed("underline green");
979
980        // Built once, by the red console...
981        let text = red.build_text("[accent]hi[/]");
982        assert_eq!(red.render_to_string(&text), "\x1b[1;31mhi\x1b[0m");
983        // ...and the green console still renders it in green.
984        assert_eq!(green.render_to_string(&text), "\x1b[4;32mhi\x1b[0m");
985    }
986
987    /// Emoji expansion and the highlighters have to run on both paths, or the
988    /// strict variant would quietly render differently from the lenient one.
989    #[test]
990    fn try_build_text_expands_emoji_like_build_text() {
991        let console = test_console();
992        assert_eq!(
993            console
994                .try_build_text(":rocket: go")
995                .expect("valid")
996                .plain(),
997            console.build_text(":rocket: go").plain()
998        );
999    }
1000
1001    #[test]
1002    fn renders_markup_string() {
1003        let console = test_console();
1004        assert_eq!(
1005            console.render_str_to_string("[bold red]hi[/]"),
1006            "\x1b[1;31mhi\x1b[0m"
1007        );
1008    }
1009
1010    #[test]
1011    fn print_justify_pads_to_width() {
1012        let console = Console::builder()
1013            .force_terminal(true)
1014            .color_system(Some(ColorSystem::Truecolor))
1015            .width(10)
1016            .build();
1017        // Captured from real rich 15.0.0: console.print("hi", justify=...).
1018        assert_eq!(
1019            console.render_justified_to_string("hi", Justify::Left),
1020            "hi        "
1021        );
1022        assert_eq!(
1023            console.render_justified_to_string("hi", Justify::Center),
1024            "    hi    "
1025        );
1026        assert_eq!(
1027            console.render_justified_to_string("hi", Justify::Right),
1028            "        hi"
1029        );
1030    }
1031
1032    #[test]
1033    fn capture_records_ansi_instead_of_stdout() {
1034        let console = Console::builder()
1035            .force_terminal(true)
1036            .color_system(Some(ColorSystem::Truecolor))
1037            .width(20)
1038            .build();
1039        // Captured from real rich 15.0.0 (Console.capture()).
1040        let out = console.capture(|c| c.print_str("[bold red]hi[/] there"));
1041        assert_eq!(out, "\x1b[1;31mhi\x1b[0m there\n");
1042    }
1043
1044    #[test]
1045    fn themed_exports_use_the_given_palette() {
1046        use crate::terminal_theme::{MONOKAI, NIGHT_OWLISH};
1047
1048        let console = Console::builder()
1049            .force_terminal(true)
1050            .color_system(Some(ColorSystem::Truecolor))
1051            .width(20)
1052            .no_color(false)
1053            .build();
1054        let render = |c: &Console| c.print_str("hi");
1055
1056        // Monokai's background is #0c0c0c and Night Owlish's is #ffffff, so the
1057        // chosen theme has to show up in the emitted CSS.
1058        let monokai = console.export_html_themed(&MONOKAI, render);
1059        assert!(
1060            monokai.contains("#0c0c0c"),
1061            "monokai bg missing:\n{monokai}"
1062        );
1063
1064        let owlish = console.export_html_themed(&NIGHT_OWLISH, render);
1065        assert!(owlish.contains("#ffffff"), "owlish bg missing:\n{owlish}");
1066        assert!(!owlish.contains("#0c0c0c"), "leaked monokai into owlish");
1067
1068        // The class form and SVG take a theme too.
1069        let classes = console.export_html_classes_themed(&MONOKAI, render);
1070        assert!(classes.contains("#0c0c0c"), "class-form ignored the theme");
1071        let svg = console.export_svg_themed(&MONOKAI, "t", "id", render);
1072        assert!(svg.contains("#0c0c0c"), "svg ignored the theme");
1073
1074        // The convenience methods keep their documented defaults.
1075        assert!(console.export_html(render).contains("#ffffff"));
1076    }
1077
1078    #[test]
1079    fn page_with_honors_the_styles_flag() {
1080        use std::sync::Mutex;
1081
1082        #[derive(Default)]
1083        struct Recorder(Mutex<String>);
1084        impl crate::pager::Pager for Recorder {
1085            fn show(&self, content: &str) -> std::io::Result<()> {
1086                *self.0.lock().unwrap() = content.to_string();
1087                Ok(())
1088            }
1089        }
1090
1091        let console = Console::builder()
1092            .force_terminal(true)
1093            .color_system(Some(ColorSystem::Truecolor))
1094            .width(20)
1095            .no_color(false)
1096            .build();
1097
1098        // styles = false (upstream's `Console.pager()` default) strips ANSI.
1099        let plain = Recorder::default();
1100        console
1101            .page_with(&plain, false, |c| c.print_str("[bold red]hi[/] there"))
1102            .unwrap();
1103        assert_eq!(plain.0.lock().unwrap().as_str(), "hi there\n");
1104
1105        // styles = true keeps it, matching `Console.pager(styles=True)`.
1106        let styled = Recorder::default();
1107        console
1108            .page_with(&styled, true, |c| c.print_str("[bold red]hi[/] there"))
1109            .unwrap();
1110        assert_eq!(
1111            styled.0.lock().unwrap().as_str(),
1112            "\x1b[1;31mhi\x1b[0m there\n"
1113        );
1114    }
1115
1116    #[test]
1117    fn export_text_strips_styles() {
1118        let console = Console::builder()
1119            .force_terminal(true)
1120            .color_system(Some(ColorSystem::Truecolor))
1121            .width(20)
1122            .build();
1123        // Captured from real rich 15.0.0 (Console.export_text(styles=False)).
1124        let out = console.export_text(|c| c.print_str("[bold red]hi[/] there"));
1125        assert_eq!(out, "hi there\n");
1126    }
1127
1128    #[test]
1129    fn export_html_matches_upstream() {
1130        let console = Console::builder()
1131            .force_terminal(true)
1132            .color_system(Some(ColorSystem::Truecolor))
1133            .width(20)
1134            .no_color(false)
1135            .build();
1136        let html = console.export_html(|c| {
1137            c.print_str("[bold red]hi[/] there");
1138            c.print_str("plain line");
1139        });
1140        // Regenerated from real rich by `scripts/capture_golden.py`, so CI's
1141        // drift check covers exports too. Keep this input in step with the
1142        // matching console in that script.
1143        let expected = include_str!("../tests/golden/export_html.html").replace("\r\n", "\n");
1144        assert_eq!(html, expected);
1145    }
1146
1147    #[test]
1148    fn export_html_classes_matches_upstream() {
1149        let console = Console::builder()
1150            .force_terminal(true)
1151            .color_system(Some(ColorSystem::Truecolor))
1152            .width(20)
1153            .no_color(false)
1154            .build();
1155        let html = console.export_html_classes(|c| c.print_str("[bold red]hi[/] there"));
1156        // As above: regenerated by `scripts/capture_golden.py`. Note this test
1157        // prints ONE line where the inline-styles test prints two.
1158        let expected =
1159            include_str!("../tests/golden/export_html_classes.html").replace("\r\n", "\n");
1160        assert_eq!(html, expected);
1161    }
1162
1163    #[test]
1164    fn capture_matches_direct_render() {
1165        let console = test_console();
1166        let panel = crate::panel::Panel::new(Box::new(Text::new("hi")));
1167        assert_eq!(
1168            console.capture(|c| c.print(&panel)),
1169            console.render_export(&panel)
1170        );
1171    }
1172
1173    #[test]
1174    fn no_color_strips_styles() {
1175        let console = Console::builder()
1176            .force_terminal(true)
1177            .color_system(None)
1178            .build();
1179        assert_eq!(console.render_str_to_string("[bold red]hi[/]"), "hi");
1180    }
1181}