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