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