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