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        let no_color = self
757            .no_color
758            .unwrap_or_else(|| std::env::var_os("NO_COLOR").is_some());
759        let color_system = if self.color_system_set {
760            self.color_system
761        } else if is_terminal {
762            Some(detect_color_system())
763        } else {
764            None
765        };
766        let width = self.width.unwrap_or_else(detect_width);
767        let height = self.height.unwrap_or_else(detect_height);
768        Console {
769            color_system,
770            width,
771            height,
772            is_terminal,
773            no_color,
774            emoji: self.emoji.unwrap_or(true),
775            // Upstream's `Console(highlight=True)` default. Getting this wrong is
776            // invisible in the fixtures (every one is captured with
777            // highlight=False) but is the first thing a user sees: numbers,
778            // paths, booleans and URLs come out plain instead of coloured.
779            highlight: self.highlight.unwrap_or(true),
780            legacy_windows: self.legacy_windows.unwrap_or(false),
781            safe_box: self.safe_box.unwrap_or(true),
782            ascii_only: self.ascii_only.unwrap_or(false),
783            theme: self.theme.unwrap_or_else(Theme::default_theme),
784            base_style: Style::new(),
785            highlighters: Vec::new(),
786            record_buffer: std::cell::RefCell::new(Vec::new()),
787            capturing: std::cell::Cell::new(false),
788        }
789    }
790}
791
792/// Detect the terminal color system from environment variables.
793fn detect_color_system() -> ColorSystem {
794    if let Some(colorterm) = std::env::var_os("COLORTERM") {
795        let colorterm = colorterm.to_string_lossy().to_ascii_lowercase();
796        if colorterm.contains("truecolor") || colorterm.contains("24bit") {
797            return ColorSystem::Truecolor;
798        }
799    }
800    if let Some(term) = std::env::var_os("TERM") {
801        if term.to_string_lossy().contains("256") {
802            return ColorSystem::EightBit;
803        }
804    }
805    ColorSystem::Standard
806}
807
808/// Detect the terminal width: `COLUMNS`, then the real terminal, then a default.
809fn detect_width() -> usize {
810    if let Some(columns) = std::env::var_os("COLUMNS") {
811        if let Ok(value) = columns.to_string_lossy().trim().parse::<usize>() {
812            if value > 0 {
813                return value;
814            }
815        }
816    }
817    if let Some((terminal_size::Width(w), _)) = terminal_size::terminal_size() {
818        if w > 0 {
819            return w as usize;
820        }
821    }
822    DEFAULT_WIDTH
823}
824
825/// Detect the terminal height: `LINES`, then the real terminal, then a default.
826fn detect_height() -> usize {
827    if let Some(lines) = std::env::var_os("LINES") {
828        if let Ok(value) = lines.to_string_lossy().trim().parse::<usize>() {
829            if value > 0 {
830                return value;
831            }
832        }
833    }
834    if let Some((_, terminal_size::Height(h))) = terminal_size::terminal_size() {
835        if h > 0 {
836            return h as usize;
837        }
838    }
839    DEFAULT_HEIGHT
840}
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845
846    fn test_console() -> Console {
847        Console::builder()
848            .force_terminal(true)
849            .color_system(Some(ColorSystem::Truecolor))
850            .width(80)
851            .no_color(false)
852            .build()
853    }
854
855    /// The strict path reports malformed markup where the lenient one prints it
856    /// literally. Both must still agree on markup that is actually valid.
857    #[test]
858    fn try_build_text_reports_bad_markup() {
859        let console = test_console();
860
861        let err = console
862            .try_build_text("[/nope]")
863            .expect_err("an unmatched closing tag must be an error");
864        assert!(
865            matches!(err, crate::errors::RichError::Markup(_)),
866            "{err:?}"
867        );
868        // The lenient path swallows it and prints the source text as-is.
869        assert_eq!(console.build_text("[/nope]").plain(), "[/nope]");
870
871        let strict = console.try_build_text("[bold]hi[/]").expect("valid markup");
872        assert_eq!(strict.plain(), "hi");
873        assert_eq!(
874            strict.spans().len(),
875            console.build_text("[bold]hi[/]").spans().len()
876        );
877    }
878
879    /// An unknown tag *name* is not an error — it renders as a no-op, tag
880    /// consumed. Only genuine syntax errors fail.
881    ///
882    /// Verified against real rich 15.0.0: `Console().print("[nope]x[/]")` writes
883    /// `x`, while `[bold]a[/italic]` raises `MarkupError`. Before names were
884    /// carried on spans, the port resolved `nope` eagerly, failed, and fell back
885    /// to printing the markup source literally.
886    #[test]
887    fn unknown_tag_names_render_as_no_ops() {
888        let console = test_console();
889        let text = console
890            .try_build_text("[nope]x[/]")
891            .expect("an unknown tag name is not a syntax error");
892        assert_eq!(console.render_to_string(&text), "x");
893        assert_eq!(
894            console.render_to_string(&console.build_text("[a.b.c]x[/]")),
895            "x"
896        );
897
898        // A mismatched closing tag is still an error, on both paths.
899        assert!(console.try_build_text("[bold]a[/italic]").is_err());
900        assert!(console.try_build_text("[/nope]").is_err());
901    }
902
903    /// Markup styles bind to the theme of the console that renders the text, not
904    /// the one that parsed it. Verified against real rich 15.0.0.
905    #[test]
906    fn markup_styles_bind_at_render_not_at_parse() {
907        let themed = |definition: &str| {
908            let mut theme = Theme::default_theme();
909            theme.insert("accent", Style::parse(definition).unwrap());
910            Console::builder()
911                .force_terminal(true)
912                .color_system(Some(ColorSystem::Truecolor))
913                .width(80)
914                .no_color(false)
915                .theme(theme)
916                .build()
917        };
918        let red = themed("bold red");
919        let green = themed("underline green");
920
921        // Built once, by the red console...
922        let text = red.build_text("[accent]hi[/]");
923        assert_eq!(red.render_to_string(&text), "\x1b[1;31mhi\x1b[0m");
924        // ...and the green console still renders it in green.
925        assert_eq!(green.render_to_string(&text), "\x1b[4;32mhi\x1b[0m");
926    }
927
928    /// Emoji expansion and the highlighters have to run on both paths, or the
929    /// strict variant would quietly render differently from the lenient one.
930    #[test]
931    fn try_build_text_expands_emoji_like_build_text() {
932        let console = test_console();
933        assert_eq!(
934            console
935                .try_build_text(":rocket: go")
936                .expect("valid")
937                .plain(),
938            console.build_text(":rocket: go").plain()
939        );
940    }
941
942    #[test]
943    fn renders_markup_string() {
944        let console = test_console();
945        assert_eq!(
946            console.render_str_to_string("[bold red]hi[/]"),
947            "\x1b[1;31mhi\x1b[0m"
948        );
949    }
950
951    #[test]
952    fn print_justify_pads_to_width() {
953        let console = Console::builder()
954            .force_terminal(true)
955            .color_system(Some(ColorSystem::Truecolor))
956            .width(10)
957            .build();
958        // Captured from real rich 15.0.0: console.print("hi", justify=...).
959        assert_eq!(
960            console.render_justified_to_string("hi", Justify::Left),
961            "hi        "
962        );
963        assert_eq!(
964            console.render_justified_to_string("hi", Justify::Center),
965            "    hi    "
966        );
967        assert_eq!(
968            console.render_justified_to_string("hi", Justify::Right),
969            "        hi"
970        );
971    }
972
973    #[test]
974    fn capture_records_ansi_instead_of_stdout() {
975        let console = Console::builder()
976            .force_terminal(true)
977            .color_system(Some(ColorSystem::Truecolor))
978            .width(20)
979            .build();
980        // Captured from real rich 15.0.0 (Console.capture()).
981        let out = console.capture(|c| c.print_str("[bold red]hi[/] there"));
982        assert_eq!(out, "\x1b[1;31mhi\x1b[0m there\n");
983    }
984
985    #[test]
986    fn themed_exports_use_the_given_palette() {
987        use crate::terminal_theme::{MONOKAI, NIGHT_OWLISH};
988
989        let console = Console::builder()
990            .force_terminal(true)
991            .color_system(Some(ColorSystem::Truecolor))
992            .width(20)
993            .no_color(false)
994            .build();
995        let render = |c: &Console| c.print_str("hi");
996
997        // Monokai's background is #0c0c0c and Night Owlish's is #ffffff, so the
998        // chosen theme has to show up in the emitted CSS.
999        let monokai = console.export_html_themed(&MONOKAI, render);
1000        assert!(
1001            monokai.contains("#0c0c0c"),
1002            "monokai bg missing:\n{monokai}"
1003        );
1004
1005        let owlish = console.export_html_themed(&NIGHT_OWLISH, render);
1006        assert!(owlish.contains("#ffffff"), "owlish bg missing:\n{owlish}");
1007        assert!(!owlish.contains("#0c0c0c"), "leaked monokai into owlish");
1008
1009        // The class form and SVG take a theme too.
1010        let classes = console.export_html_classes_themed(&MONOKAI, render);
1011        assert!(classes.contains("#0c0c0c"), "class-form ignored the theme");
1012        let svg = console.export_svg_themed(&MONOKAI, "t", "id", render);
1013        assert!(svg.contains("#0c0c0c"), "svg ignored the theme");
1014
1015        // The convenience methods keep their documented defaults.
1016        assert!(console.export_html(render).contains("#ffffff"));
1017    }
1018
1019    #[test]
1020    fn page_with_honors_the_styles_flag() {
1021        use std::sync::Mutex;
1022
1023        #[derive(Default)]
1024        struct Recorder(Mutex<String>);
1025        impl crate::pager::Pager for Recorder {
1026            fn show(&self, content: &str) -> std::io::Result<()> {
1027                *self.0.lock().unwrap() = content.to_string();
1028                Ok(())
1029            }
1030        }
1031
1032        let console = Console::builder()
1033            .force_terminal(true)
1034            .color_system(Some(ColorSystem::Truecolor))
1035            .width(20)
1036            .no_color(false)
1037            .build();
1038
1039        // styles = false (upstream's `Console.pager()` default) strips ANSI.
1040        let plain = Recorder::default();
1041        console
1042            .page_with(&plain, false, |c| c.print_str("[bold red]hi[/] there"))
1043            .unwrap();
1044        assert_eq!(plain.0.lock().unwrap().as_str(), "hi there\n");
1045
1046        // styles = true keeps it, matching `Console.pager(styles=True)`.
1047        let styled = Recorder::default();
1048        console
1049            .page_with(&styled, true, |c| c.print_str("[bold red]hi[/] there"))
1050            .unwrap();
1051        assert_eq!(
1052            styled.0.lock().unwrap().as_str(),
1053            "\x1b[1;31mhi\x1b[0m there\n"
1054        );
1055    }
1056
1057    #[test]
1058    fn export_text_strips_styles() {
1059        let console = Console::builder()
1060            .force_terminal(true)
1061            .color_system(Some(ColorSystem::Truecolor))
1062            .width(20)
1063            .build();
1064        // Captured from real rich 15.0.0 (Console.export_text(styles=False)).
1065        let out = console.export_text(|c| c.print_str("[bold red]hi[/] there"));
1066        assert_eq!(out, "hi there\n");
1067    }
1068
1069    #[test]
1070    fn export_html_matches_upstream() {
1071        let console = Console::builder()
1072            .force_terminal(true)
1073            .color_system(Some(ColorSystem::Truecolor))
1074            .width(20)
1075            .no_color(false)
1076            .build();
1077        let html = console.export_html(|c| {
1078            c.print_str("[bold red]hi[/] there");
1079            c.print_str("plain line");
1080        });
1081        // Regenerated from real rich by `scripts/capture_golden.py`, so CI's
1082        // drift check covers exports too. Keep this input in step with the
1083        // matching console in that script.
1084        let expected = include_str!("../tests/golden/export_html.html").replace("\r\n", "\n");
1085        assert_eq!(html, expected);
1086    }
1087
1088    #[test]
1089    fn export_html_classes_matches_upstream() {
1090        let console = Console::builder()
1091            .force_terminal(true)
1092            .color_system(Some(ColorSystem::Truecolor))
1093            .width(20)
1094            .no_color(false)
1095            .build();
1096        let html = console.export_html_classes(|c| c.print_str("[bold red]hi[/] there"));
1097        // As above: regenerated by `scripts/capture_golden.py`. Note this test
1098        // prints ONE line where the inline-styles test prints two.
1099        let expected =
1100            include_str!("../tests/golden/export_html_classes.html").replace("\r\n", "\n");
1101        assert_eq!(html, expected);
1102    }
1103
1104    #[test]
1105    fn capture_matches_direct_render() {
1106        let console = test_console();
1107        let panel = crate::panel::Panel::new(Box::new(Text::new("hi")));
1108        assert_eq!(
1109            console.capture(|c| c.print(&panel)),
1110            console.render_export(&panel)
1111        );
1112    }
1113
1114    #[test]
1115    fn no_color_strips_styles() {
1116        let console = Console::builder()
1117            .force_terminal(true)
1118            .color_system(None)
1119            .build();
1120        assert_eq!(console.render_str_to_string("[bold red]hi[/]"), "hi");
1121    }
1122}