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