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 /// Highlight override for strings rendered under these options, or `None`
80 /// for the console default. Mirrors `ConsoleOptions.highlight`: `Panel`,
81 /// `Table` and `Tree` set it for their children, and upstream's
82 /// `Console.render` passes it to `render_str` for a `str` renderable.
83 pub highlight: Option<bool>,
84 /// Markup override for strings rendered under these options, or `None` for
85 /// the console default. Mirrors `ConsoleOptions.markup`.
86 pub markup: Option<bool>,
87 /// Height of the container (starts as the terminal height). Mirrors
88 /// `ConsoleOptions.max_height`.
89 pub max_height: usize,
90 /// Encoding of the terminal (`"utf-8"`, or `"ascii"` for an ASCII-only
91 /// console). Mirrors `ConsoleOptions.encoding`.
92 pub encoding: String,
93 /// Whether the target is a terminal. Mirrors `ConsoleOptions.is_terminal`.
94 pub is_terminal: bool,
95 /// Whether the target is a legacy Windows console. Mirrors
96 /// `ConsoleOptions.legacy_windows`.
97 pub legacy_windows: bool,
98 /// The size of the console. Mirrors `ConsoleOptions.size`.
99 pub size: ConsoleDimensions,
100}
101
102/// The size of a console in cells. Mirrors `rich.console.ConsoleDimensions`.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
104pub struct ConsoleDimensions {
105 /// Width in cells.
106 pub width: usize,
107 /// Height in rows.
108 pub height: usize,
109}
110
111impl Default for ConsoleOptions {
112 /// The options of a default 80x25 UTF-8 console, as
113 /// [`Console::options`] builds them.
114 fn default() -> Self {
115 ConsoleOptions {
116 min_width: 1,
117 max_width: DEFAULT_WIDTH,
118 height: None,
119 justify: Justify::Default,
120 overflow: None,
121 no_wrap: None,
122 highlight: None,
123 markup: None,
124 max_height: DEFAULT_HEIGHT,
125 encoding: "utf-8".to_string(),
126 is_terminal: false,
127 legacy_windows: false,
128 size: ConsoleDimensions {
129 width: DEFAULT_WIDTH,
130 height: DEFAULT_HEIGHT,
131 },
132 }
133 }
134}
135
136impl ConsoleOptions {
137 /// Return a copy with `max_width` (and a clamped `min_width`) updated.
138 /// Port of `ConsoleOptions.update_width`.
139 pub fn update_width(&self, width: usize) -> ConsoleOptions {
140 // Copy-then-overwrite rather than a fresh literal, so fields added later
141 // are carried through instead of being silently reset to a default.
142 let mut options = self.clone();
143 options.min_width = width;
144 options.max_width = width;
145 options
146 }
147
148 /// Return a copy with both width and height pinned. Port of
149 /// `ConsoleOptions.update_dimensions`.
150 pub fn update_dimensions(&self, width: usize, height: usize) -> ConsoleOptions {
151 let mut options = self.update_width(width);
152 options.height = Some(height);
153 options.max_height = height;
154 options
155 }
156
157 /// Return a copy with the height (and `max_height`) set. Port of
158 /// `ConsoleOptions.update_height`.
159 pub fn update_height(&self, height: usize) -> ConsoleOptions {
160 let mut options = self.clone();
161 options.height = Some(height);
162 options.max_height = height;
163 options
164 }
165
166 /// Return a copy with `height` cleared. Port of
167 /// `ConsoleOptions.reset_height`.
168 pub fn reset_height(&self) -> ConsoleOptions {
169 let mut options = self.clone();
170 options.height = None;
171 options
172 }
173
174 /// Whether renderables should use ASCII only: the encoding is not a UTF
175 /// one. Port of the `ConsoleOptions.ascii_only` property.
176 pub fn ascii_only(&self) -> bool {
177 !self.encoding.starts_with("utf")
178 }
179}
180
181/// Keyword arguments of upstream's `Console.render_str`, for
182/// [`Console::render_str_with`]. `None` takes the console's default.
183#[derive(Clone, Default)]
184pub struct RenderStrOptions<'a> {
185 /// Base style of the result (`style=`, default none).
186 pub style: crate::style::StyleType,
187 /// `justify=`; `None` leaves the text's justify unset.
188 pub justify: Option<Justify>,
189 /// `overflow=`; `None` leaves the text's overflow unset.
190 pub overflow: Option<Overflow>,
191 /// `emoji=`: replace emoji codes, or `None` for the console default.
192 pub emoji: Option<bool>,
193 /// `markup=`: parse console markup, or `None` for the console default.
194 pub markup: Option<bool>,
195 /// `highlight=`: highlight, or `None` for the console default.
196 pub highlight: Option<bool>,
197 /// `highlighter=`: highlight with this instead of the console's
198 /// highlighters (registered ones plus `ReprHighlighter`).
199 pub highlighter: Option<&'a dyn Highlighter>,
200}
201
202/// Per-thread capture buffer stacks (innermost capture last).
203type CaptureStacks = std::collections::HashMap<std::thread::ThreadId, Vec<Vec<Segment>>>;
204
205/// The calling thread's innermost capture buffer, if it is capturing.
206fn current_capture(captures: &mut CaptureStacks) -> Option<&mut Vec<Segment>> {
207 captures
208 .get_mut(&std::thread::current().id())
209 .and_then(|stack| stack.last_mut())
210}
211
212/// The high-level interface for rendering to a terminal. Mirrors
213/// `rich.console.Console`.
214pub struct Console {
215 render_environment: Option<std::sync::Arc<dyn crate::protocol::RenderEnvironment>>,
216 /// The semantic-region observer (see `ConsoleRegions`); none by default.
217 region_sink: Option<std::sync::Arc<dyn crate::protocol::RegionSink>>,
218 /// The default code highlighter (see `ConsoleCodeHighlighting`).
219 code_highlighting: Option<crate::protocol::CodeHighlighting>,
220 color_system: Option<ColorSystem>,
221 width: usize,
222 height: usize,
223 is_terminal: bool,
224 no_color: bool,
225 /// Upstream's `Console.get_time`: the clock animations read.
226 get_time: GetTime,
227 emoji: bool,
228 highlight: bool,
229 legacy_windows: bool,
230 safe_box: bool,
231 ascii_only: bool,
232 /// Upstream's `ThemeStack`: the builder's theme at the bottom, pushed
233 /// themes above it. Never empty; styles resolve against the top entry.
234 theme_stack: Vec<Theme>,
235 base_style: Style,
236 /// Registered highlighters. Shared (`Arc`) so a [`Clone`] of the console
237 /// keeps them; each is behind a lock so the console is `Sync`.
238 highlighters: Vec<std::sync::Arc<dyn Highlighter + Send + Sync>>,
239 /// Upstream's `Console(markup=…)`: whether printed strings are parsed as
240 /// console markup.
241 markup: bool,
242 /// Upstream's `Console(emoji_variant=…)`: the variant appended to emoji
243 /// codes that name none.
244 emoji_variant: Option<crate::emoji::EmojiVariant>,
245 /// Upstream's `Console(tab_size=…)`: the tab stop width `Text` expands to.
246 tab_size: usize,
247 /// While a thread is capturing, its print paths append their segments to
248 /// the innermost buffer of its stack here instead of writing to stdout.
249 /// Mirrors upstream's `ConsoleThreadLocals.buffer`: capture state is per
250 /// thread, so a thread printing while another captures is not swallowed
251 /// by that capture, and concurrent captures never mix. Each nested
252 /// capture on a thread pushes a buffer and pops it when it ends.
253 captures: std::sync::Mutex<CaptureStacks>,
254 /// Upstream's `Console._is_alt_screen`: set by
255 /// [`set_alt_screen`](Console::set_alt_screen).
256 is_alt_screen: std::sync::atomic::AtomicBool,
257}
258
259/// A `Send`-only highlighter behind a lock, so it can be shared by a `Sync`
260/// console.
261struct LockedHighlighter(std::sync::Mutex<Box<dyn Highlighter + Send>>);
262
263impl Highlighter for LockedHighlighter {
264 fn highlight(&self, text: &mut Text) {
265 let highlighter = self
266 .0
267 .lock()
268 .unwrap_or_else(std::sync::PoisonError::into_inner);
269 highlighter.highlight(text);
270 }
271}
272
273/// `NoAltScreen("Alt screen must be enabled to call update_screen")`.
274fn no_alt_screen() -> crate::errors::RichError {
275 crate::errors::RichError::NoAltScreen(
276 "Alt screen must be enabled to call update_screen".to_string(),
277 )
278}
279
280/// Rendered lines placed at an offset on the screen: each line is preceded
281/// by a cursor move to its row. Port of `rich.console.ScreenUpdate`.
282pub struct ScreenUpdate {
283 lines: Vec<Vec<Segment>>,
284 x: usize,
285 y: usize,
286}
287
288impl ScreenUpdate {
289 /// `lines` drawn from column `x`, row `y`.
290 pub fn new(lines: Vec<Vec<Segment>>, x: usize, y: usize) -> Self {
291 ScreenUpdate { lines, x, y }
292 }
293}
294
295impl Renderable for ScreenUpdate {
296 fn rich_render(&self, _console: &Console, _options: &ConsoleOptions) -> Vec<Segment> {
297 let mut segments = Vec::new();
298 for (offset, line) in self.lines.iter().enumerate() {
299 // `Control.move_to(x, y + offset)`, widened so huge coordinates
300 // are written in full (upstream's ints are unbounded).
301 let to = crate::control::move_to_code(self.x as u128, self.y as u128 + offset as u128);
302 segments.push(Segment::control(to));
303 segments.extend(line.iter().cloned());
304 }
305 segments
306 }
307}
308
309impl Clone for Console {
310 /// An independent console with the same configuration, theme stack and
311 /// highlighters (shared). The clone starts with an empty capture buffer
312 /// and is not capturing, whatever the original is doing.
313 fn clone(&self) -> Self {
314 Console {
315 render_environment: self.render_environment.clone(),
316 region_sink: self.region_sink.clone(),
317 code_highlighting: self.code_highlighting.clone(),
318 color_system: self.color_system,
319 width: self.width,
320 height: self.height,
321 is_terminal: self.is_terminal,
322 no_color: self.no_color,
323 get_time: self.get_time.clone(),
324 emoji: self.emoji,
325 highlight: self.highlight,
326 legacy_windows: self.legacy_windows,
327 safe_box: self.safe_box,
328 ascii_only: self.ascii_only,
329 theme_stack: self.theme_stack.clone(),
330 base_style: self.base_style.clone(),
331 highlighters: self.highlighters.clone(),
332 markup: self.markup,
333 emoji_variant: self.emoji_variant,
334 tab_size: self.tab_size,
335 captures: std::sync::Mutex::new(CaptureStacks::new()),
336 is_alt_screen: std::sync::atomic::AtomicBool::new(false),
337 }
338 }
339}
340
341/// A theme in use on a [`Console`] until this guard drops. Returned by
342/// [`Console::use_theme`]; upstream's `ThemeContext`.
343pub struct ThemeContext<'a> {
344 console: &'a mut Console,
345}
346
347impl std::ops::Deref for ThemeContext<'_> {
348 type Target = Console;
349
350 fn deref(&self) -> &Console {
351 self.console
352 }
353}
354
355impl std::ops::DerefMut for ThemeContext<'_> {
356 fn deref_mut(&mut self) -> &mut Console {
357 self.console
358 }
359}
360
361impl Drop for ThemeContext<'_> {
362 fn drop(&mut self) {
363 // Upstream's `__exit__` pops unconditionally. This only fails if the
364 // caller already popped back down to the base through the guard.
365 let _ = self.console.pop_theme();
366 }
367}
368
369impl Default for Console {
370 fn default() -> Self {
371 Console::new()
372 }
373}
374
375impl Console {
376 /// Auto-detect terminal capabilities from the environment.
377 pub fn new() -> Self {
378 ConsoleBuilder::new().build()
379 }
380
381 /// Start configuring a console explicitly (used by tests and `rich-ext`).
382 pub fn builder() -> ConsoleBuilder {
383 ConsoleBuilder::new()
384 }
385
386 /// The active color system, or `None` when styles are not rendered at
387 /// all. Port of `Console.color_system`.
388 ///
389 /// Like upstream, this is independent of [`no_color`](Self::no_color):
390 /// no-colour mode keeps the colour system and strips only the colours at
391 /// output time, so bold, italic, underline and the like still render.
392 /// Callers asking "will colour reach the terminal?" must check both.
393 pub fn color_system(&self) -> Option<ColorSystem> {
394 self.color_system
395 }
396
397 /// Whether colour output is disabled. Port of `Console.no_color`: set by
398 /// the builder, or by a non-empty `NO_COLOR` environment variable.
399 pub fn no_color(&self) -> bool {
400 self.no_color
401 }
402
403 /// The current time in seconds from this console's clock. Port of
404 /// `Console.get_time` (default `time.monotonic`); override it with
405 /// [`ConsoleBuilder::get_time`] for deterministic animation.
406 pub fn get_time(&self) -> f64 {
407 (self.get_time)()
408 }
409
410 /// The detected (or configured) width in cells.
411 pub fn width(&self) -> usize {
412 self.width
413 }
414
415 /// The detected (or configured) height in rows. Used by height-aware
416 /// renderables such as [`Layout`](crate::layout::Layout).
417 pub fn height(&self) -> usize {
418 self.height
419 }
420
421 /// Whether output is going to a real terminal.
422 pub fn is_terminal(&self) -> bool {
423 self.is_terminal
424 }
425
426 /// Whether output targets a legacy Windows console (drives box substitution).
427 pub fn legacy_windows(&self) -> bool {
428 self.legacy_windows
429 }
430
431 /// Whether to substitute box glyphs for terminal-safe variants (default on).
432 pub fn safe_box(&self) -> bool {
433 self.safe_box
434 }
435
436 /// Whether the terminal can only render ASCII (forces the `ASCII` box).
437 pub fn ascii_only(&self) -> bool {
438 self.ascii_only
439 }
440
441 /// The active theme: the top of the theme stack.
442 pub fn theme(&self) -> &Theme {
443 self.theme_stack
444 .last()
445 .expect("the theme stack always holds its base theme")
446 }
447
448 /// Resolve a style name (or pass a style through) against this console's
449 /// theme. Port of `Console.get_style`.
450 pub fn get_style(&self, style: &crate::style::StyleType) -> crate::errors::Result<Style> {
451 self.theme().get_style(style)
452 }
453
454 /// Push a theme on to the top of the stack. Port of `Console.push_theme`.
455 ///
456 /// With `inherit` the new top is the current top's styles overridden by
457 /// `theme`'s; without it, the new top is exactly `theme`. Prefer
458 /// [`use_theme`](Self::use_theme), which pops again automatically.
459 pub fn push_theme(&mut self, theme: Theme, inherit: bool) {
460 let top = if inherit {
461 let mut merged = self.theme().clone();
462 merged.extend_from(&theme);
463 merged
464 } else {
465 theme
466 };
467 self.theme_stack.push(top);
468 }
469
470 /// Remove the top theme, restoring the previous one. Port of
471 /// `Console.pop_theme`; popping the base theme is an error
472 /// (upstream's `ThemeStackError("Unable to pop base theme")`).
473 pub fn pop_theme(&mut self) -> crate::errors::Result<()> {
474 if self.theme_stack.len() == 1 {
475 return Err(crate::errors::RichError::ThemeStack(
476 "Unable to pop base theme".to_string(),
477 ));
478 }
479 self.theme_stack.pop();
480 Ok(())
481 }
482
483 /// Use a theme until the returned guard is dropped. Port of
484 /// `Console.use_theme`, Python's context manager as an RAII guard.
485 ///
486 /// The guard dereferences to the console, so print *through the guard*
487 /// while it is alive; dropping it pops the theme, including during a
488 /// panic unwind.
489 ///
490 /// ```
491 /// # use rich::{Console, Theme, Style};
492 /// let mut console = Console::builder().width(20).build();
493 /// let mut theme = Theme::new();
494 /// theme.insert("warning", Style::parse("bold red").unwrap());
495 /// {
496 /// let themed = console.use_theme(theme);
497 /// assert!(themed.theme().get("warning").is_some());
498 /// }
499 /// assert!(console.theme().get("warning").is_none());
500 /// ```
501 ///
502 /// Upstream's `use_theme` also takes `inherit`, but its `ThemeContext`
503 /// never passes it on to `push_theme`, so a used theme always inherits
504 /// (verified against rich 15.0.0). This port keeps that behaviour and
505 /// omits the ignored parameter; call [`push_theme`](Self::push_theme) to
506 /// replace the styles outright.
507 pub fn use_theme(&mut self, theme: Theme) -> ThemeContext<'_> {
508 self.push_theme(theme, true);
509 ThemeContext { console: self }
510 }
511
512 /// The whole-output base style.
513 pub fn base_style(&self) -> &Style {
514 &self.base_style
515 }
516
517 /// Register a highlighter. **The core plugin seam** — see docs/PLUGINS.md.
518 /// The highlighter must be `Send` so a [`Console`](Console) can move to a
519 /// background thread (e.g. an auto-refreshing [`Live`](crate::live::Live)).
520 pub fn add_highlighter(&mut self, highlighter: Box<dyn Highlighter + Send>) {
521 self.highlighters
522 .push(std::sync::Arc::new(LockedHighlighter(
523 std::sync::Mutex::new(highlighter),
524 )));
525 }
526
527 /// The default render options for this console (full width, no height).
528 pub fn options(&self) -> ConsoleOptions {
529 ConsoleOptions {
530 min_width: 1,
531 max_width: self.width,
532 height: None,
533 justify: Justify::Default,
534 overflow: None,
535 no_wrap: None,
536 highlight: None,
537 markup: None,
538 max_height: self.height,
539 encoding: self.encoding().to_string(),
540 is_terminal: self.is_terminal,
541 legacy_windows: self.legacy_windows,
542 size: self.size(),
543 }
544 }
545
546 /// The size of the console. Port of the `Console.size` property.
547 pub fn size(&self) -> ConsoleDimensions {
548 ConsoleDimensions {
549 width: self.width,
550 height: self.height,
551 }
552 }
553
554 /// The output encoding: `"ascii"` for an [ASCII-only](Self::ascii_only)
555 /// console, else `"utf-8"`. Port of the `Console.encoding` property (which
556 /// reads the output file's encoding; this port has no file to ask).
557 pub fn encoding(&self) -> &'static str {
558 if self.ascii_only {
559 "ascii"
560 } else {
561 "utf-8"
562 }
563 }
564
565 /// Render a value to an ANSI string (no trailing newline). Primarily for
566 /// tests and inline rendering.
567 ///
568 /// When no explicit justify is requested, the width is first shrunk to the
569 /// renderable's measured width (matching upstream's measurement-fit for a
570 /// bare top-level renderable).
571 pub fn render_to_string(&self, renderable: &dyn Renderable) -> String {
572 let segments = self.render_segments(renderable);
573 self.segments_to_string(&segments)
574 }
575
576 /// Render a renderable to segments as `Console.print` does (shared by the
577 /// string and print paths): a printed `Text` goes through upstream's
578 /// `Text.join`, and an extension that opts into measurement-fit is shrunk
579 /// to its measured width when no explicit justify is set.
580 fn render_segments(&self, renderable: &dyn Renderable) -> Vec<Segment> {
581 self.render_segments_with(renderable, &self.options())
582 }
583
584 /// Render a renderable to segments with explicit render options, exactly
585 /// as [`print_with`](Self::print_with) does before writing: a printed
586 /// `Text` goes through upstream's `Text.join`, a renderable that opts in
587 /// is fitted to its measurement, and the result is cropped to the console
588 /// width (`print(crop=True)`). No trailing newline is added.
589 ///
590 /// For upstream's lower-level `Console.render`, see [`render`](Self::render).
591 pub fn render_segments_with(
592 &self,
593 renderable: &dyn Renderable,
594 options: &ConsoleOptions,
595 ) -> Vec<Segment> {
596 let mut options = options.clone();
597 let joined;
598 let joined_text = renderable.printed_text();
599 let renderable = match joined_text.clone() {
600 Some(text) => {
601 // `print(justify="left"|"center"|"right")` wraps the joined
602 // text in `Align`, which renders a zero-width text (such as an
603 // empty one) at no width at all: nothing is printed.
604 if matches!(
605 options.justify,
606 Justify::Left | Justify::Center | Justify::Right
607 ) && text.measurement().1 == 0
608 {
609 return Vec::new();
610 }
611 joined = text;
612 &joined as &dyn Renderable
613 }
614 None => renderable,
615 };
616 // `print(justify="left"|"center"|"right")` wraps every other
617 // renderable in `Align(renderable, justify)` (`_collect_renderables`).
618 let align = match options.justify {
619 Justify::Left if joined_text.is_none() => Some(crate::align::HorizontalAlign::Left),
620 Justify::Center if joined_text.is_none() => Some(crate::align::HorizontalAlign::Center),
621 Justify::Right if joined_text.is_none() => Some(crate::align::HorizontalAlign::Right),
622 _ => None,
623 };
624 if options.justify == Justify::Default && renderable.fit_to_measurement() {
625 let measurement = renderable.measure(self, &options);
626 options.max_width = measurement.maximum.min(options.max_width).max(1);
627 }
628 let segments = match align {
629 // `Console.render` yields nothing when there is no width.
630 _ if options.max_width < 1 => Vec::new(),
631 Some(align) => crate::align::Align::render_child(renderable, align, self, &options),
632 None => renderable.rich_render(self, &options),
633 };
634 // `Console.print(crop=True)`: the final backstop against a line running
635 // off the side of the terminal. Renderables that fit are untouched; this
636 // is what gives `Overflow::Ignore` its "wrap nothing, but still don't
637 // corrupt the display" behaviour.
638 Segment::crop_lines(&segments, self.width)
639 }
640
641 /// Render a renderable to segments. Port of `Console.render`: `options`
642 /// defaults to [`options`](Self::options), and nothing is rendered when
643 /// there is no width (`max_width < 1`).
644 ///
645 /// Unlike the print path ([`render_segments_with`](Self::render_segments_with))
646 /// the renderable is rendered as-is: no `Text.join`, no fitting and no
647 /// crop. Container renderables use this for their children.
648 ///
649 /// As everywhere in this port, lines are *separated* by newline segments:
650 /// the final line has no trailing `"\n"` where upstream's has one.
651 pub fn render(
652 &self,
653 renderable: &dyn Renderable,
654 options: Option<&ConsoleOptions>,
655 ) -> Vec<Segment> {
656 let default_options;
657 let options = match options {
658 Some(options) => options,
659 None => {
660 default_options = self.options();
661 &default_options
662 }
663 };
664 if options.max_width < 1 {
665 return Vec::new();
666 }
667 renderable.rich_render(self, options)
668 }
669
670 /// Write (or, while capturing, record) a rendered segment stream, adding a
671 /// trailing newline. The single sink for every `print*` path.
672 fn emit(&self, segments: Vec<Segment>) {
673 self.emit_end(segments, true);
674 }
675
676 /// [`emit`](Self::emit), with the trailing newline optional: upstream's
677 /// `print(…, end="")` when `newline` is false. Output written straight to
678 /// stdout is flushed in that case, so a prompt shows before input is read.
679 fn emit_end(&self, segments: Vec<Segment>, newline: bool) {
680 if segments.is_empty() {
681 return;
682 }
683 let segments = {
684 let mut captures = self.lock_captures();
685 if let Some(buffer) = current_capture(&mut captures) {
686 buffer.extend(segments);
687 if newline {
688 buffer.push(Segment::line());
689 }
690 return;
691 }
692 segments
693 };
694 let mut output = self.segments_to_string(&segments);
695 if newline {
696 output.push('\n');
697 }
698 let stdout = std::io::stdout();
699 let mut lock = stdout.lock();
700 let _ = write!(lock, "{output}");
701 if !newline {
702 let _ = lock.flush();
703 }
704 }
705
706 /// Display `prompt` and read a line of input from standard input. Port of
707 /// `Console.input`: the prompt is console markup, printed through the
708 /// console with `end=""` so it is captured and exported like any other
709 /// output. `None` means end of input.
710 pub fn input(&self, prompt: &str) -> std::io::Result<Option<String>> {
711 let prompt = self.build_text(prompt);
712 self.input_from(&prompt, &mut crate::prompt::StdinInput)
713 }
714
715 /// [`input`](Self::input) with a renderable prompt (upstream accepts a
716 /// `Text`) and an explicit input source — upstream's `stream=` argument.
717 pub fn input_from(
718 &self,
719 prompt: &dyn Renderable,
720 stream: &mut dyn crate::prompt::InputSource,
721 ) -> std::io::Result<Option<String>> {
722 // `if prompt: self.print(prompt, end="")`.
723 let segments = self.render_segments(prompt);
724 if segments.iter().any(|segment| !segment.text.is_empty()) {
725 self.emit_end(segments, false);
726 }
727 stream.read_line()
728 }
729
730 /// Render a value into a list of lines, each a list of [`Segment`]s.
731 ///
732 /// Port of `Console.render_lines`. When `pad` is true, every line is padded
733 /// (or cropped) to `options.max_width` — this is what container renderables
734 /// such as `Panel`/`Padding` rely on to get uniform-width child rows.
735 pub fn render_lines(
736 &self,
737 renderable: &dyn Renderable,
738 options: &ConsoleOptions,
739 pad: bool,
740 ) -> Vec<Vec<Segment>> {
741 self.render_lines_styled(renderable, options, None, pad)
742 }
743
744 /// [`render_lines`](Self::render_lines) with upstream's `style=` argument:
745 /// the style is applied under every rendered segment and to the padding
746 /// that fills each line, as `Panel` and `Padding` use it.
747 pub fn render_lines_styled(
748 &self,
749 renderable: &dyn Renderable,
750 options: &ConsoleOptions,
751 style: Option<&Style>,
752 pad: bool,
753 ) -> Vec<Vec<Segment>> {
754 // Upstream pads with the `style` argument as given (default `None`),
755 // so the pad segments carry no style unless one was passed.
756 let pad_style = style.cloned();
757 let style = style.filter(|style| !style.is_null());
758 // Upstream `Console.render` yields nothing when `max_width < 1`, so a
759 // renderable squeezed to zero width contributes no lines (#449).
760 let mut segments = if options.max_width < 1 {
761 Vec::new()
762 } else {
763 renderable.rich_render(self, options)
764 };
765 if let Some(style) = style {
766 segments = Segment::apply_style(&segments, style);
767 }
768 let mut lines = Segment::split_lines(&segments);
769 // An empty `Text` renders as a lone empty segment: upstream renders it
770 // as its `end` newline, which `split_and_crop_lines` turns into one
771 // blank line (#442).
772 if lines.is_empty()
773 && !segments.is_empty()
774 && segments
775 .iter()
776 .all(|segment| !segment.control && segment.text.is_empty())
777 {
778 lines.push(Vec::new());
779 }
780 if pad {
781 for line in &mut lines {
782 *line = Segment::adjust_line_length(line, options.max_width, pad_style.clone());
783 }
784 }
785 // Honor an explicit height by cropping/padding to exactly that many rows
786 // (matching `Console.render_lines`'s height handling — used by height-
787 // aware containers such as `Panel` inside a `Layout`).
788 if let Some(height) = options.height {
789 lines.truncate(height);
790 while lines.len() < height {
791 lines.push(if pad {
792 vec![Segment::new(
793 " ".repeat(options.max_width),
794 pad_style.clone(),
795 )]
796 } else {
797 Vec::new()
798 });
799 }
800 }
801 lines
802 }
803
804 /// Render a value exactly as [`print`](Console::print) would write it,
805 /// returning the string (including the single trailing newline). For tests
806 /// and export.
807 pub fn render_export(&self, renderable: &dyn Renderable) -> String {
808 let segments = self.render_segments(renderable);
809 let mut out = self.segments_to_string(&segments);
810 if !segments.is_empty() {
811 out.push('\n');
812 }
813 out
814 }
815
816 /// Render a value and write it to stdout, followed by a newline.
817 pub fn print(&self, renderable: &dyn Renderable) {
818 let segments = self.render_segments(renderable);
819 self.emit(segments);
820 }
821
822 /// Print with explicit render options, the equivalent of upstream's
823 /// `Console.print(renderable, justify=…, overflow=…, no_wrap=…)`. Start
824 /// from [`options`](Self::options) and set the fields to override. A
825 /// printed `Text` defers to these options, because upstream's `Text.join`
826 /// drops the text's own `justify`, `overflow` and `no_wrap`.
827 pub fn print_with(&self, renderable: &dyn Renderable, options: &ConsoleOptions) {
828 let segments = self.render_segments_with(renderable, options);
829 self.emit(segments);
830 }
831
832 /// Like [`render_export`](Self::render_export), with explicit render
833 /// options as for [`print_with`](Self::print_with).
834 pub fn render_export_with(
835 &self,
836 renderable: &dyn Renderable,
837 options: &ConsoleOptions,
838 ) -> String {
839 let segments = self.render_segments_with(renderable, options);
840 let mut out = self.segments_to_string(&segments);
841 if !segments.is_empty() {
842 out.push('\n');
843 }
844 out
845 }
846
847 /// Write a terminal control sequence to stdout.
848 ///
849 /// Port of `Console.control`. Control codes are only written when output is
850 /// a real terminal (they are meaningless when redirected to a file).
851 pub fn control(&self, control: &crate::control::Control) {
852 if !self.is_terminal {
853 return;
854 }
855 let text = control.as_str();
856 // Upstream buffers control codes with everything else, so a capture
857 // records them.
858 if let Some(buffer) = current_capture(&mut self.lock_captures()) {
859 if !text.is_empty() {
860 buffer.push(Segment::control(text));
861 }
862 return;
863 }
864 if !text.is_empty() {
865 let stdout = std::io::stdout();
866 let mut lock = stdout.lock();
867 let _ = write!(lock, "{text}");
868 }
869 }
870
871 /// Whether the alternate screen is enabled. Port of
872 /// `Console.is_alt_screen`.
873 pub fn is_alt_screen(&self) -> bool {
874 self.is_alt_screen.load(std::sync::atomic::Ordering::SeqCst)
875 }
876
877 /// Enable or disable the alternate screen. Port of
878 /// `Console.set_alt_screen`: only a terminal (not a legacy Windows
879 /// console) switches, and the result says whether it did.
880 pub fn set_alt_screen(&self, enable: bool) -> bool {
881 if self.is_terminal && !self.legacy_windows {
882 self.control(&crate::control::Control::alt_screen(enable));
883 self.is_alt_screen
884 .store(enable, std::sync::atomic::Ordering::SeqCst);
885 return true;
886 }
887 false
888 }
889
890 /// Render `renderable` into `region` of the alternate screen (the whole
891 /// screen when `None`). Port of `Console.update_screen`.
892 pub fn update_screen(
893 &self,
894 renderable: &dyn Renderable,
895 region: Option<crate::region::Region>,
896 options: Option<&ConsoleOptions>,
897 ) -> crate::errors::Result<()> {
898 if !self.is_alt_screen() {
899 return Err(no_alt_screen());
900 }
901 let render_options = options.cloned().unwrap_or_else(|| self.options());
902 let (x, y, render_options) = match region {
903 None => {
904 let height = render_options
905 .height
906 .filter(|&height| height > 0)
907 .unwrap_or(self.height);
908 let width = render_options.max_width;
909 (0, 0, render_options.update_dimensions(width, height))
910 }
911 Some(region) => (
912 region.x,
913 region.y,
914 render_options.update_dimensions(region.width, region.height),
915 ),
916 };
917 let lines = self.render_lines(renderable, &render_options, true);
918 self.update_screen_lines(&lines, x, y)
919 }
920
921 /// Write rendered `lines` to the alternate screen at column `x`, row `y`.
922 /// Port of `Console.update_screen_lines` (through [`ScreenUpdate`]).
923 pub fn update_screen_lines(
924 &self,
925 lines: &[Vec<Segment>],
926 x: usize,
927 y: usize,
928 ) -> crate::errors::Result<()> {
929 if !self.is_alt_screen() {
930 return Err(no_alt_screen());
931 }
932 let update = ScreenUpdate::new(lines.to_vec(), x, y);
933 let segments = self.render(&update, None);
934 self.emit_end(segments, false);
935 Ok(())
936 }
937
938 /// Show or hide the cursor. Port of `Console.show_cursor`.
939 pub fn show_cursor(&self, show: bool) {
940 self.control(&crate::control::Control::show_cursor(show));
941 }
942
943 /// Clear the screen. Port of `Console.clear`.
944 pub fn clear(&self) {
945 self.control(&crate::control::Control::clear());
946 }
947
948 /// Ring the terminal bell. Port of `Console.bell`.
949 pub fn bell(&self) {
950 self.control(&crate::control::Control::bell());
951 }
952
953 /// Capture everything printed inside `f` instead of writing it to stdout,
954 /// returning it as a rendered (ANSI) string.
955 ///
956 /// The Rust analogue of upstream's `with console.capture() as capture:` —
957 /// the closure receives the same console, and captures nest correctly.
958 /// Equivalent to what would have been written to the terminal.
959 pub fn capture(&self, f: impl FnOnce(&Console)) -> String {
960 let segments = self.record(f);
961 self.segments_to_string(&segments)
962 }
963
964 /// Like [`capture`](Self::capture) but with all styles stripped, returning
965 /// plain text. Port of `Console.export_text(styles=False)`.
966 pub fn export_text(&self, f: impl FnOnce(&Console)) -> String {
967 let segments = self.record(f);
968 segments_to_plain(&segments)
969 }
970
971 /// Buffer everything printed inside `f` and display it through the system
972 /// pager. The Rust analogue of upstream's `with console.pager():` block.
973 ///
974 /// Styles are stripped unless `styles` is set, matching
975 /// `Console.pager(styles=False)`. When there's no terminal to page in (piped
976 /// output, `TERM=dumb`) or no pager can be started, the content is written
977 /// straight to stdout.
978 pub fn page(&self, styles: bool, f: impl FnOnce(&Console)) -> std::io::Result<()> {
979 self.page_with(&crate::pager::SystemPager, styles, f)
980 }
981
982 /// Like [`page`](Self::page) but with an explicit [`Pager`](crate::pager::Pager)
983 /// — the seam upstream exposes as `Console.pager(pager=…)`.
984 pub fn page_with(
985 &self,
986 pager: &dyn crate::pager::Pager,
987 styles: bool,
988 f: impl FnOnce(&Console),
989 ) -> std::io::Result<()> {
990 let segments = self.record(f);
991 let content = if styles {
992 self.segments_to_string(&segments)
993 } else {
994 segments_to_plain(&segments)
995 };
996 pager.show(&content)
997 }
998
999 /// Capture output printed inside `f` and export it as a self-contained HTML
1000 /// document (inline styles), using the default terminal theme. Port of
1001 /// `Console.export_html(inline_styles=True)`.
1002 pub fn export_html(&self, f: impl FnOnce(&Console)) -> String {
1003 self.export_html_themed(&crate::terminal_theme::DEFAULT_TERMINAL_THEME, f)
1004 }
1005
1006 /// Like [`export_html`](Self::export_html) but with an explicit palette —
1007 /// upstream's `export_html(theme=…)`. See [`terminal_theme`] for the
1008 /// bundled presets.
1009 ///
1010 /// [`terminal_theme`]: crate::terminal_theme
1011 pub fn export_html_themed(
1012 &self,
1013 theme: &crate::terminal_theme::TerminalTheme,
1014 f: impl FnOnce(&Console),
1015 ) -> String {
1016 let segments = self.record(f);
1017 crate::export::export_html_inline(&segments, theme)
1018 }
1019
1020 /// Like [`export_html`](Self::export_html) but with a generated CSS-class
1021 /// stylesheet (`.r1 {…}`) instead of inline styles. Port of upstream's
1022 /// default `Console.export_html(inline_styles=False)`.
1023 pub fn export_html_classes(&self, f: impl FnOnce(&Console)) -> String {
1024 self.export_html_classes_themed(&crate::terminal_theme::DEFAULT_TERMINAL_THEME, f)
1025 }
1026
1027 /// Like [`export_html_classes`](Self::export_html_classes) but with an
1028 /// explicit palette — upstream's `export_html(theme=…, inline_styles=False)`.
1029 pub fn export_html_classes_themed(
1030 &self,
1031 theme: &crate::terminal_theme::TerminalTheme,
1032 f: impl FnOnce(&Console),
1033 ) -> String {
1034 let segments = self.record(f);
1035 crate::export::export_html_classes(&segments, theme)
1036 }
1037
1038 /// Capture output printed inside `f` and export it as a self-contained SVG
1039 /// image of a terminal window, using [`SVG_EXPORT_THEME`]. Port of
1040 /// `Console.export_svg`.
1041 ///
1042 /// `unique_id` prefixes every generated id/class. Upstream's auto-computed
1043 /// default hashes Python `repr()` output (not reproducible in Rust), so this
1044 /// port takes an explicit id; output is byte-parity with
1045 /// `export_svg(title=…, unique_id=…)` (see docs/DIVERGENCES.md #15).
1046 ///
1047 /// [`SVG_EXPORT_THEME`]: crate::terminal_theme::SVG_EXPORT_THEME
1048 pub fn export_svg(&self, title: &str, unique_id: &str, f: impl FnOnce(&Console)) -> String {
1049 self.export_svg_themed(
1050 &crate::terminal_theme::SVG_EXPORT_THEME,
1051 title,
1052 unique_id,
1053 f,
1054 )
1055 }
1056
1057 /// Like [`export_svg`](Self::export_svg) but with an explicit palette —
1058 /// upstream's `export_svg(theme=…)`.
1059 pub fn export_svg_themed(
1060 &self,
1061 theme: &crate::terminal_theme::TerminalTheme,
1062 title: &str,
1063 unique_id: &str,
1064 f: impl FnOnce(&Console),
1065 ) -> String {
1066 let segments = self.record(f);
1067 crate::svg::export_svg(&segments, theme, title, unique_id, self.width())
1068 }
1069
1070 /// Capture output printed inside `f` and export it as HTML with every
1071 /// option of upstream's `Console.export_html`: `code_format` (default
1072 /// [`CONSOLE_HTML_FORMAT`](crate::export::CONSOLE_HTML_FORMAT)) and
1073 /// `inline_styles`. See [`export::export_html_with`](crate::export::export_html_with).
1074 pub fn export_html_with(
1075 &self,
1076 theme: &crate::terminal_theme::TerminalTheme,
1077 code_format: Option<&str>,
1078 inline_styles: bool,
1079 f: impl FnOnce(&Console),
1080 ) -> Result<String, crate::export::ExportFormatError> {
1081 let segments = self.record(f);
1082 crate::export::export_html_with(&segments, theme, code_format, inline_styles)
1083 }
1084
1085 /// Capture output printed inside `f` and export it as SVG with every
1086 /// option of upstream's `Console.export_svg`: `code_format` (default
1087 /// [`CONSOLE_SVG_FORMAT`](crate::svg::CONSOLE_SVG_FORMAT)) and
1088 /// `font_aspect_ratio` (upstream default 0.61). See
1089 /// [`svg::export_svg_with`](crate::svg::export_svg_with).
1090 #[allow(clippy::too_many_arguments)]
1091 pub fn export_svg_with(
1092 &self,
1093 theme: &crate::terminal_theme::TerminalTheme,
1094 title: &str,
1095 unique_id: &str,
1096 code_format: Option<&str>,
1097 font_aspect_ratio: f64,
1098 f: impl FnOnce(&Console),
1099 ) -> Result<String, crate::export::ExportFormatError> {
1100 let segments = self.record(f);
1101 crate::svg::export_svg_with(
1102 &segments,
1103 theme,
1104 title,
1105 unique_id,
1106 self.width(),
1107 code_format.unwrap_or(crate::svg::CONSOLE_SVG_FORMAT),
1108 font_aspect_ratio,
1109 )
1110 }
1111
1112 /// Record everything `f` prints and hand back the raw segments, without
1113 /// writing to the terminal.
1114 ///
1115 /// This is the seam for producing *several* outputs from one render — the
1116 /// terminal bytes and an HTML and an SVG file, say — which is what
1117 /// `rich --export-html … --export-svg …` needs. Upstream reaches the same
1118 /// place with `Console(record=True)` plus `save_html(clear=False)`; here the
1119 /// buffer is returned instead of being held on the console, so the caller
1120 /// decides what to do with it and there is no hidden state to clear.
1121 ///
1122 /// Pair with [`segments_to_string`](Self::segments_to_string) to get the
1123 /// terminal form, [`export::export_html_classes`](crate::export::export_html_classes)
1124 /// for HTML, and [`svg::export_svg`](crate::svg::export_svg) for SVG.
1125 ///
1126 /// Rendering twice instead would be wrong, not merely wasteful: a renderable
1127 /// reading standard input only yields its content once.
1128 pub fn record_output(&self, f: impl FnOnce(&Console)) -> Vec<Segment> {
1129 self.record(f)
1130 }
1131
1132 /// Run `f` with this thread's output recorded to a fresh buffer, returning
1133 /// the captured segments. Captures nest (each pushes its own buffer), are
1134 /// per thread (upstream's `ConsoleThreadLocals`), and end even if `f`
1135 /// panics (upstream's `Capture.__exit__` always runs).
1136 fn record(&self, f: impl FnOnce(&Console)) -> Vec<Segment> {
1137 /// Pops this thread's capture buffer on drop, so an unwinding `f`
1138 /// does not leave the console capturing.
1139 struct CaptureGuard<'a>(&'a Console);
1140 impl CaptureGuard<'_> {
1141 fn pop(&self) -> Vec<Segment> {
1142 let id = std::thread::current().id();
1143 let mut captures = self.0.lock_captures();
1144 let Some(stack) = captures.get_mut(&id) else {
1145 return Vec::new();
1146 };
1147 let captured = stack.pop().unwrap_or_default();
1148 if stack.is_empty() {
1149 captures.remove(&id);
1150 }
1151 captured
1152 }
1153 }
1154 impl Drop for CaptureGuard<'_> {
1155 fn drop(&mut self) {
1156 self.pop();
1157 }
1158 }
1159 self.lock_captures()
1160 .entry(std::thread::current().id())
1161 .or_default()
1162 .push(Vec::new());
1163 let guard = CaptureGuard(self);
1164 f(self);
1165 let captured = guard.pop();
1166 std::mem::forget(guard);
1167 captured
1168 }
1169
1170 /// The per-thread capture stacks, recovering them if a panicking print
1171 /// poisoned the lock.
1172 fn lock_captures(&self) -> std::sync::MutexGuard<'_, CaptureStacks> {
1173 self.captures
1174 .lock()
1175 .unwrap_or_else(std::sync::PoisonError::into_inner)
1176 }
1177
1178 /// Parse `content` as console markup, apply registered highlighters, and
1179 /// print it. This is the `console.print("...")` path.
1180 pub fn print_str(&self, content: &str) {
1181 let text = self.build_text(content);
1182 self.print(&text);
1183 }
1184
1185 /// Same as [`Console::print_str`] but returns the ANSI string.
1186 pub fn render_str_to_string(&self, content: &str) -> String {
1187 let text = self.build_text(content);
1188 self.render_to_string(&text)
1189 }
1190
1191 /// Parse `content` as console markup (expanding emoji + applying the active
1192 /// highlighters), returning the styled [`Text`] that `print_str` would print.
1193 /// Exposed so callers can wrap the markup in another renderable.
1194 pub fn build_text(&self, content: &str) -> Text {
1195 // Malformed markup falls back to printing the text as-is. Upstream would
1196 // raise `MarkupError` instead; use `try_build_text` (or `try_print_str`)
1197 // when the markup comes from a user and a mistake should be reported
1198 // rather than rendered. See docs/DIVERGENCES.md §2.
1199 self.try_build_text(content)
1200 .unwrap_or_else(|_| self.decorate(Text::new(self.expand_emoji(content))))
1201 }
1202
1203 /// As [`build_text`](Console::build_text), but returns
1204 /// [`RichError::Markup`](crate::errors::RichError::Markup) for malformed
1205 /// markup instead of falling back to the raw text — upstream's behaviour.
1206 pub fn try_build_text(&self, content: &str) -> crate::errors::Result<Text> {
1207 if !self.markup {
1208 // `Console(markup=False)`: the string is taken literally.
1209 return Ok(self.decorate(Text::new(self.expand_emoji_plain(content))));
1210 }
1211 let markup = self.parse_markup(content, self.emoji)?;
1212
1213 // The highlighter runs on the *markup-stripped* text and its spans go on
1214 // first; the markup spans are appended afterwards. Spans combine in
1215 // order, so this is what makes an explicit tag beat the highlighter —
1216 // `[green]123[/]` is green, not `repr.number` cyan.
1217 //
1218 // Upstream reaches the same result a different way: `Console.render_str`
1219 // highlights a fresh `Text(str(rich_text))` and then calls
1220 // `highlight_text.copy_styles(rich_text)`, whose `_spans.extend` appends
1221 // the markup spans last. Decorating the markup `Text` in place — the
1222 // obvious reading — inverts the precedence.
1223 let mut text = self.decorate(Text::new(markup.plain()));
1224 for span in markup.spans() {
1225 text.push_span(span.clone());
1226 }
1227 Ok(text)
1228 }
1229
1230 /// Convert a plain string to [`Text`] the way a `str` renderable is
1231 /// converted upstream: emoji codes expand (per the console), console
1232 /// markup is parsed, and highlighting runs when `highlight` (or, when
1233 /// `None`, the console default) enables it. Port of `Console.render_str`,
1234 /// which `Table` cells, `Tree` labels and `Columns` items go through.
1235 ///
1236 /// Malformed markup falls back to the literal text, as
1237 /// [`build_text`](Console::build_text) does (docs/DIVERGENCES.md §2).
1238 pub fn render_str(&self, content: &str, highlight: Option<bool>) -> Text {
1239 let highlight = highlight.unwrap_or(self.highlight);
1240 // `markup.render` returns the (emoji-replaced) string untouched when it
1241 // holds no `[`; skip the parser for the common plain cell.
1242 let markup = if !self.markup {
1243 Text::new(self.expand_emoji_plain(content))
1244 } else if content.contains('[') {
1245 self.parse_markup(content, self.emoji)
1246 .unwrap_or_else(|_| Text::new(self.expand_emoji(content)))
1247 } else if content.contains(':') {
1248 Text::new(self.expand_emoji(content))
1249 } else {
1250 Text::new(content)
1251 };
1252 if !highlight {
1253 return markup;
1254 }
1255 // Highlight the plain text, then append the markup spans, as
1256 // `highlight_text.copy_styles(rich_text)` does (see `try_build_text`).
1257 let mut text = self.decorate_with_repr(Text::new(markup.plain()));
1258 for span in markup.spans() {
1259 text.push_span(span.clone());
1260 }
1261 text
1262 }
1263
1264 /// As [`print_str`](Console::print_str), but reports malformed markup.
1265 pub fn try_print_str(&self, content: &str) -> crate::errors::Result<()> {
1266 self.print(&self.try_build_text(content)?);
1267 Ok(())
1268 }
1269
1270 /// As [`print_justified`](Console::print_justified), but reports malformed
1271 /// markup.
1272 pub fn try_print_justified(
1273 &self,
1274 content: &str,
1275 justify: Justify,
1276 ) -> crate::errors::Result<()> {
1277 let text = self.try_build_text(content)?;
1278 let mut options = self.options();
1279 options.justify = justify;
1280 self.emit(text.rich_render(self, &options));
1281 Ok(())
1282 }
1283
1284 /// Expand `:emoji:` shortcodes. Runs before markup parsing (matching
1285 /// upstream's default `emoji=True`); `:name:` and `[tag]` don't overlap.
1286 ///
1287 /// The console's default emoji variant applies only when `content` holds
1288 /// no `[`: upstream's `markup.render` passes `default_variant` on its
1289 /// tag-free fast path only, and replaces the text between tags without it.
1290 pub(crate) fn expand_emoji(&self, content: &str) -> String {
1291 if !self.emoji {
1292 return content.to_string();
1293 }
1294 let variant = if content.contains('[') {
1295 None
1296 } else {
1297 self.emoji_variant
1298 };
1299 crate::emoji::replace_with_variant(content, variant)
1300 }
1301
1302 /// Port of `markup.render(content, emoji=emoji, emoji_variant=…)`: a
1303 /// string with no `[` is only emoji-replaced (with the console's default
1304 /// variant); otherwise emoji codes are replaced chunk by chunk between the
1305 /// tags, so error positions refer to the original string.
1306 fn parse_markup(&self, content: &str, emoji: bool) -> crate::errors::Result<Text> {
1307 if !content.contains('[') {
1308 return Ok(Text::new(if emoji {
1309 crate::emoji::replace_with_variant(content, self.emoji_variant)
1310 } else {
1311 content.to_string()
1312 }));
1313 }
1314 if emoji {
1315 crate::markup::render_emoji(content)
1316 } else {
1317 crate::markup::render(content)
1318 }
1319 }
1320
1321 /// Expand `:emoji:` shortcodes in a string that is not markup, with the
1322 /// console's default variant (upstream's `markup=False` branch of
1323 /// `render_str`).
1324 fn expand_emoji_plain(&self, content: &str) -> String {
1325 if self.emoji {
1326 crate::emoji::replace_with_variant(content, self.emoji_variant)
1327 } else {
1328 content.to_string()
1329 }
1330 }
1331
1332 /// Convert a string to [`Text`] with every keyword upstream's
1333 /// `Console.render_str` takes. Port of `Console.render_str`, strict:
1334 /// malformed markup is an error (`MarkupError`), not the literal text.
1335 ///
1336 /// Emoji, markup and highlighting default to the console's settings when
1337 /// the option is `None`. As upstream, a highlighted result is a fresh
1338 /// `Text` carrying only spans (the highlighter's, then the markup's): the
1339 /// `style`, `justify` and `overflow` are dropped by `copy_styles`.
1340 pub fn render_str_with(
1341 &self,
1342 content: &str,
1343 options: &RenderStrOptions<'_>,
1344 ) -> crate::errors::Result<Text> {
1345 let emoji = options.emoji.unwrap_or(self.emoji);
1346 let markup = options.markup.unwrap_or(self.markup);
1347 let highlight = options.highlight.unwrap_or(self.highlight);
1348
1349 let mut rich_text = if markup {
1350 self.parse_markup(content, emoji)?
1351 } else if emoji {
1352 Text::new(crate::emoji::replace_with_variant(
1353 content,
1354 self.emoji_variant,
1355 ))
1356 } else {
1357 Text::new(content)
1358 };
1359 rich_text.set_base_style(options.style.clone());
1360 rich_text.set_justify(options.justify.unwrap_or_default());
1361 rich_text.set_overflow(options.overflow);
1362
1363 if !highlight {
1364 return Ok(rich_text);
1365 }
1366 let mut text = Text::new(rich_text.plain());
1367 match options.highlighter {
1368 Some(highlighter) => highlighter.highlight(&mut text),
1369 None => text = self.decorate_with_repr(text),
1370 }
1371 for span in rich_text.spans() {
1372 text.push_span(span.clone());
1373 }
1374 Ok(text)
1375 }
1376
1377 /// Whether `:emoji:` codes are replaced by default. Port of
1378 /// `Console(emoji=…)`.
1379 pub fn emoji(&self) -> bool {
1380 self.emoji
1381 }
1382
1383 /// Whether printed strings are highlighted by default. Port of
1384 /// `Console(highlight=…)`.
1385 pub fn highlight(&self) -> bool {
1386 self.highlight
1387 }
1388
1389 /// Whether printed strings are parsed as console markup by default. Port
1390 /// of `Console(markup=…)`.
1391 pub fn markup(&self) -> bool {
1392 self.markup
1393 }
1394
1395 /// The default emoji variant. Port of `Console(emoji_variant=…)`.
1396 pub fn emoji_variant(&self) -> Option<crate::emoji::EmojiVariant> {
1397 self.emoji_variant
1398 }
1399
1400 /// The tab stop width `Text` expands tabs to. Port of `Console.tab_size`.
1401 pub fn tab_size(&self) -> usize {
1402 self.tab_size
1403 }
1404
1405 /// Change the tab stop width. Upstream's `Console.tab_size` is a plain
1406 /// attribute.
1407 pub fn set_tab_size(&mut self, tab_size: usize) {
1408 self.tab_size = tab_size;
1409 }
1410
1411 /// Set the terminal window title. Port of `Console.set_window_title`:
1412 /// only a terminal is sent the code, and the return value says whether it
1413 /// was.
1414 pub fn set_window_title(&self, title: &str) -> bool {
1415 if self.is_terminal {
1416 self.control(&crate::control::Control::title(title));
1417 true
1418 } else {
1419 false
1420 }
1421 }
1422
1423 /// Apply the registered highlighters, plus the built-in `ReprHighlighter`
1424 /// when `highlight` is on.
1425 fn decorate(&self, mut text: Text) -> Text {
1426 for highlighter in &self.highlighters {
1427 highlighter.highlight(&mut text);
1428 }
1429 if self.highlight {
1430 crate::highlighter::ReprHighlighter::new().highlight(&mut text);
1431 }
1432 text
1433 }
1434
1435 /// [`decorate`](Self::decorate) for a caller that has already decided to
1436 /// highlight (upstream's `highlight=True` override of the console default).
1437 fn decorate_with_repr(&self, mut text: Text) -> Text {
1438 for highlighter in &self.highlighters {
1439 highlighter.highlight(&mut text);
1440 }
1441 crate::highlighter::ReprHighlighter::new().highlight(&mut text);
1442 text
1443 }
1444
1445 /// Parse `content` as markup and print it justified to the console width.
1446 /// This is the `console.print("...", justify=...)` path.
1447 pub fn print_justified(&self, content: &str, justify: Justify) {
1448 let text = self.build_text(content);
1449 let mut options = self.options();
1450 options.justify = justify;
1451 let segments = text.rich_render(self, &options);
1452 self.emit(segments);
1453 }
1454
1455 /// Same as [`Console::print_justified`] but returns the ANSI string.
1456 ///
1457 /// The justify is passed via `options.justify`, which — matching upstream —
1458 /// disables the measurement-fit so the text pads to the full width.
1459 pub fn render_justified_to_string(&self, content: &str, justify: Justify) -> String {
1460 let text = self.build_text(content);
1461 let mut options = self.options();
1462 options.justify = justify;
1463 let segments = text.rich_render(self, &options);
1464 self.segments_to_string(&segments)
1465 }
1466
1467 /// Convert rendered segments into a terminal string, applying this console's
1468 /// colour system (and honouring `no_color`). Port of `Console._render_buffer`.
1469 pub fn segments_to_string(&self, segments: &[Segment]) -> String {
1470 let system = self.color_system;
1471 // `if self.no_color and color_system: buffer = Segment.remove_color(…)`:
1472 // colours go, every other attribute stays.
1473 let colorless;
1474 let segments = if self.no_color && system.is_some() {
1475 colorless = Segment::remove_color(segments);
1476 &colorless[..]
1477 } else {
1478 segments
1479 };
1480 let mut out = String::new();
1481 for segment in segments {
1482 // Control codes are meaningless off a terminal — upstream's
1483 // `_render_buffer` drops them when `not is_terminal`.
1484 if segment.control && !self.is_terminal {
1485 continue;
1486 }
1487 match (&segment.style, system) {
1488 (Some(style), Some(sys)) => out.push_str(&style.render(&segment.text, Some(sys))),
1489 _ => out.push_str(&segment.text),
1490 }
1491 }
1492 out
1493 }
1494}
1495
1496/// Join the visible text of a segment stream, dropping control codes. Port of
1497/// `Console.export_text(styles=False)`'s join.
1498fn segments_to_plain(segments: &[Segment]) -> String {
1499 segments
1500 .iter()
1501 .filter(|s| !s.control)
1502 .map(|s| s.text.as_str())
1503 .collect()
1504}
1505
1506/// A string renders as upstream's `Console.render` renders a `str`: through
1507/// [`Console::render_str`] with the options' `highlight` and `markup`
1508/// (`None` taking the console's defaults), then as that `Text`. Malformed
1509/// markup falls back to the literal text, as `render_str` does.
1510impl Renderable for String {
1511 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
1512 self.options_text(console, options, options.highlight)
1513 .rich_render(console, options)
1514 }
1515
1516 /// `Measurement.get` of a `str`: `render_str(markup=options.markup,
1517 /// highlight=False)`, then the text's measurement.
1518 fn measure(&self, console: &Console, options: &ConsoleOptions) -> crate::measure::Measurement {
1519 let text = self.options_text(console, options, Some(false));
1520 crate::measure::Measurement::get(console, options, &text)
1521 }
1522}
1523
1524/// `render_str` for a `str` renderable under `options`.
1525trait OptionsText {
1526 fn options_text(
1527 &self,
1528 console: &Console,
1529 options: &ConsoleOptions,
1530 highlight: Option<bool>,
1531 ) -> Text;
1532}
1533
1534impl OptionsText for String {
1535 fn options_text(
1536 &self,
1537 console: &Console,
1538 options: &ConsoleOptions,
1539 highlight: Option<bool>,
1540 ) -> Text {
1541 let render_options = RenderStrOptions {
1542 highlight,
1543 markup: options.markup,
1544 ..Default::default()
1545 };
1546 console
1547 .render_str_with(self, &render_options)
1548 .unwrap_or_else(|_| {
1549 console
1550 .render_str_with(
1551 self,
1552 &RenderStrOptions {
1553 markup: Some(false),
1554 ..render_options
1555 },
1556 )
1557 .unwrap_or_else(|_| Text::new(self.as_str()))
1558 })
1559 }
1560}
1561
1562impl Renderable for Text {
1563 fn printed_text(&self) -> Option<Text> {
1564 // `Text("").join([self])`: the text and its spans survive; justify,
1565 // overflow and no_wrap come from the blank separator (#446). The base
1566 // style does not: `join` takes the separator's (none) and re-applies
1567 // this text's as a leading span (`if text.style: append_span(...)`),
1568 // so print-level justify padding is left unstyled.
1569 let mut text = self.clone();
1570 text.clear_layout_options();
1571 text.base_style_to_span();
1572 Some(text)
1573 }
1574
1575 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
1576 // Empty Text still represents a printable blank line; an empty
1577 // generator such as Markdown does not. Preserve that distinction.
1578 // Wrap to the available width; the effective justify is this text's own
1579 // justify, falling back to the console options' justify.
1580 let justify = self.get_justify_option().unwrap_or(options.justify);
1581 // A justified empty line is still padded to the width (upstream's
1582 // `truncate(width, pad=True)`), so only an unjustified one is bare.
1583 if self.is_empty() && matches!(justify, Justify::Default | Justify::Full) {
1584 return vec![Segment::new("", None)];
1585 }
1586 // Same precedence for overflow and no_wrap: the text's own setting wins,
1587 // then the options', then upstream's default. Mirrors the `self.x or
1588 // options.x or DEFAULT` chain in `Text.__rich_console__`.
1589 let overflow = self
1590 .get_overflow()
1591 .or(options.overflow)
1592 .unwrap_or(Overflow::Fold);
1593 let no_wrap = self.get_no_wrap().or(options.no_wrap).unwrap_or(false);
1594 // `tab_size = console.tab_size if self.tab_size is None else …`.
1595 self.render_joined_wrapped_tabs(
1596 console.theme(),
1597 console.base_style(),
1598 options.max_width,
1599 justify,
1600 overflow,
1601 no_wrap,
1602 self.console_tab_size(console),
1603 )
1604 }
1605
1606 fn measure(&self, _console: &Console, options: &ConsoleOptions) -> crate::measure::Measurement {
1607 let (minimum, maximum) = self.measurement();
1608 crate::measure::Measurement::new(
1609 minimum.min(options.max_width),
1610 maximum.min(options.max_width),
1611 )
1612 }
1613}
1614
1615/// Builder for [`Console`], allowing detection to be overridden.
1616pub struct ConsoleBuilder {
1617 force_terminal: Option<bool>,
1618 color_system: Option<ColorSystem>,
1619 color_system_set: bool,
1620 width: Option<usize>,
1621 height: Option<usize>,
1622 no_color: Option<bool>,
1623 get_time: Option<GetTime>,
1624 emoji: Option<bool>,
1625 highlight: Option<bool>,
1626 legacy_windows: Option<bool>,
1627 safe_box: Option<bool>,
1628 ascii_only: Option<bool>,
1629 theme: Option<Theme>,
1630 markup: Option<bool>,
1631 emoji_variant: Option<crate::emoji::EmojiVariant>,
1632 tab_size: Option<usize>,
1633}
1634
1635impl ConsoleBuilder {
1636 fn new() -> Self {
1637 ConsoleBuilder {
1638 force_terminal: None,
1639 color_system: None,
1640 color_system_set: false,
1641 width: None,
1642 height: None,
1643 no_color: None,
1644 get_time: None,
1645 emoji: None,
1646 highlight: None,
1647 legacy_windows: None,
1648 safe_box: None,
1649 ascii_only: None,
1650 theme: None,
1651 markup: None,
1652 emoji_variant: None,
1653 tab_size: None,
1654 }
1655 }
1656
1657 /// Enable/disable console markup in printed strings (default enabled).
1658 /// Port of `Console(markup=…)`.
1659 pub fn markup(mut self, value: bool) -> Self {
1660 self.markup = Some(value);
1661 self
1662 }
1663
1664 /// The emoji variant appended to codes that name none (default none).
1665 /// Port of `Console(emoji_variant=…)`.
1666 pub fn emoji_variant(mut self, variant: Option<crate::emoji::EmojiVariant>) -> Self {
1667 self.emoji_variant = variant;
1668 self
1669 }
1670
1671 /// The tab stop width `Text` expands tabs to (default 8). Port of
1672 /// `Console(tab_size=…)`.
1673 pub fn tab_size(mut self, tab_size: usize) -> Self {
1674 self.tab_size = Some(tab_size);
1675 self
1676 }
1677
1678 pub fn force_terminal(mut self, value: bool) -> Self {
1679 self.force_terminal = Some(value);
1680 self
1681 }
1682
1683 /// Force legacy-Windows-console behavior (box substitution). Default off.
1684 pub fn legacy_windows(mut self, value: bool) -> Self {
1685 self.legacy_windows = Some(value);
1686 self
1687 }
1688
1689 /// Enable/disable terminal-safe box substitution (default on).
1690 pub fn safe_box(mut self, value: bool) -> Self {
1691 self.safe_box = Some(value);
1692 self
1693 }
1694
1695 /// Force ASCII-only box rendering (default off). Set for non-UTF-8 terminals.
1696 pub fn ascii_only(mut self, value: bool) -> Self {
1697 self.ascii_only = Some(value);
1698 self
1699 }
1700
1701 /// Force a specific color system (use for reproducible output/tests).
1702 pub fn color_system(mut self, system: Option<ColorSystem>) -> Self {
1703 self.color_system = system;
1704 self.color_system_set = true;
1705 self
1706 }
1707
1708 pub fn width(mut self, width: usize) -> Self {
1709 self.width = Some(width);
1710 self
1711 }
1712
1713 /// Set the console height in rows (used by [`Layout`](crate::layout::Layout)).
1714 pub fn height(mut self, height: usize) -> Self {
1715 self.height = Some(height);
1716 self
1717 }
1718
1719 /// Enable no-colour mode: colours are stripped from output while other
1720 /// attributes (bold, underline, …) still render. Unset, a non-empty
1721 /// `NO_COLOR` environment variable enables it. Port of `no_color=`.
1722 pub fn no_color(mut self, value: bool) -> Self {
1723 self.no_color = Some(value);
1724 self
1725 }
1726
1727 /// Read the current time (seconds) from `clock` instead of the monotonic
1728 /// clock. Port of `Console(get_time=…)`; animations such as
1729 /// [`Spinner`](crate::spinner::Spinner) render the frame for this time.
1730 pub fn get_time(mut self, clock: impl Fn() -> f64 + Send + Sync + 'static) -> Self {
1731 self.get_time = Some(std::sync::Arc::new(clock));
1732 self
1733 }
1734
1735 /// Enable/disable `:emoji:` shortcode replacement (default enabled).
1736 pub fn emoji(mut self, value: bool) -> Self {
1737 self.emoji = Some(value);
1738 self
1739 }
1740
1741 /// Enable/disable automatic repr highlighting. Defaults to **on**, matching
1742 /// upstream `Console(highlight=True)`.
1743 pub fn highlight(mut self, value: bool) -> Self {
1744 self.highlight = Some(value);
1745 self
1746 }
1747
1748 pub fn theme(mut self, theme: Theme) -> Self {
1749 self.theme = Some(theme);
1750 self
1751 }
1752
1753 pub fn build(self) -> Console {
1754 let is_terminal = self
1755 .force_terminal
1756 .unwrap_or_else(|| std::io::stdout().is_terminal());
1757 // Upstream's rule is `environ.get("NO_COLOR", "") != ""`, so an EMPTY
1758 // NO_COLOR does not disable colour — only a non-empty value does. That
1759 // matters because a shell that exports `NO_COLOR=` (a common way to
1760 // clear it) would otherwise still be treated as opting out.
1761 let no_color = self
1762 .no_color
1763 .unwrap_or_else(|| std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty()));
1764 let color_system = if self.color_system_set {
1765 self.color_system
1766 } else if is_terminal && !is_dumb_terminal() {
1767 Some(detect_color_system())
1768 } else {
1769 None
1770 };
1771 let width = self.width.unwrap_or_else(detect_width);
1772 let height = self.height.unwrap_or_else(detect_height);
1773 Console {
1774 render_environment: None,
1775 region_sink: None,
1776 code_highlighting: None,
1777 color_system,
1778 width,
1779 height,
1780 is_terminal,
1781 no_color,
1782 get_time: self
1783 .get_time
1784 .unwrap_or_else(|| std::sync::Arc::new(monotonic)),
1785 emoji: self.emoji.unwrap_or(true),
1786 // Upstream's `Console(highlight=True)` default. Getting this wrong is
1787 // invisible in the fixtures (every one is captured with
1788 // highlight=False) but is the first thing a user sees: numbers,
1789 // paths, booleans and URLs come out plain instead of coloured.
1790 highlight: self.highlight.unwrap_or(true),
1791 legacy_windows: self.legacy_windows.unwrap_or(false),
1792 safe_box: self.safe_box.unwrap_or(true),
1793 ascii_only: self.ascii_only.unwrap_or(false),
1794 theme_stack: vec![self.theme.unwrap_or_else(Theme::default_theme)],
1795 base_style: Style::new(),
1796 highlighters: Vec::new(),
1797 markup: self.markup.unwrap_or(true),
1798 emoji_variant: self.emoji_variant,
1799 // `tab_size: int = 8`.
1800 tab_size: self.tab_size.unwrap_or(crate::text::DEFAULT_TAB_SIZE),
1801 captures: std::sync::Mutex::new(CaptureStacks::new()),
1802 is_alt_screen: std::sync::atomic::AtomicBool::new(false),
1803 }
1804 }
1805}
1806
1807/// Whether `TERM` names a terminal that cannot render styles. Port of
1808/// `Console.is_dumb_terminal` (the caller supplies the `is_terminal` half):
1809/// `_detect_color_system` returns no colour system for one, so its output is
1810/// plain even though it is a terminal.
1811fn is_dumb_terminal() -> bool {
1812 std::env::var("TERM")
1813 .map(|term| matches!(term.to_lowercase().as_str(), "dumb" | "unknown"))
1814 .unwrap_or(false)
1815}
1816
1817/// Detect the terminal color system.
1818///
1819/// `COLORTERM`/`TERM` are the portable signals, but **Windows sets neither**.
1820/// Detecting from them alone meant every Windows console fell back to
1821/// [`ColorSystem::Standard`] — 16 colors — for all output. Measured on a real
1822/// Windows Terminal session: 28 distinct colors in a rendered heat map against
1823/// 140 once truecolor was detected.
1824///
1825/// Upstream `rich` special-cases Windows for the same reason. It reaches the
1826/// platform APIs directly; we ask `anstyle-query`, which avoids hand-written
1827/// `unsafe` FFI for a console handle (see `docs/DIVERGENCES.md`).
1828fn detect_color_system() -> ColorSystem {
1829 if let Some(colorterm) = std::env::var_os("COLORTERM") {
1830 let colorterm = colorterm.to_string_lossy().to_ascii_lowercase();
1831 if colorterm.contains("truecolor") || colorterm.contains("24bit") {
1832 return ColorSystem::Truecolor;
1833 }
1834 }
1835
1836 // Windows. This function is only reached when stdout is a terminal (see
1837 // ConsoleBuilder::build), and every modern Windows console that can be a
1838 // terminal speaks 24-bit color, so report truecolor.
1839 //
1840 // The call below is for its SIDE EFFECT — it turns on
1841 // ENABLE_VIRTUAL_TERMINAL_PROCESSING, which legacy `conhost` needs before
1842 // it honours any escape sequence. Its RETURN VALUE is deliberately ignored:
1843 // it enables VT on stdout *and stderr* and propagates failure with `?`, so
1844 // merely redirecting stderr (`rich ... 2>log`, the most natural CI
1845 // invocation) made it report failure and dropped the whole console to 16
1846 // colors — even though stdout was still a fully capable terminal.
1847 #[cfg(windows)]
1848 {
1849 let _ = anstyle_query::windows::enable_ansi_colors();
1850 ColorSystem::Truecolor
1851 }
1852
1853 // `TERM` is meaningless on Windows and the branch above always returns, so
1854 // gating this keeps either platform free of unreachable code.
1855 #[cfg(not(windows))]
1856 {
1857 if let Some(term) = std::env::var_os("TERM") {
1858 if term.to_string_lossy().contains("256") {
1859 return ColorSystem::EightBit;
1860 }
1861 }
1862 ColorSystem::Standard
1863 }
1864}
1865
1866/// Detect the terminal width: `COLUMNS`, then the real terminal, then a default.
1867fn detect_width() -> usize {
1868 if let Some(columns) = std::env::var_os("COLUMNS") {
1869 if let Ok(value) = columns.to_string_lossy().trim().parse::<usize>() {
1870 if value > 0 {
1871 return value;
1872 }
1873 }
1874 }
1875 if let Some((terminal_size::Width(w), _)) = terminal_size::terminal_size() {
1876 if w > 0 {
1877 return w as usize;
1878 }
1879 }
1880 DEFAULT_WIDTH
1881}
1882
1883/// Detect the terminal height: `LINES`, then the real terminal, then a default.
1884fn detect_height() -> usize {
1885 if let Some(lines) = std::env::var_os("LINES") {
1886 if let Ok(value) = lines.to_string_lossy().trim().parse::<usize>() {
1887 if value > 0 {
1888 return value;
1889 }
1890 }
1891 }
1892 if let Some((_, terminal_size::Height(h))) = terminal_size::terminal_size() {
1893 if h > 0 {
1894 return h as usize;
1895 }
1896 }
1897 DEFAULT_HEIGHT
1898}
1899
1900impl crate::protocol::ConsoleCodeHighlighting for Console {
1901 fn set_code_highlighting(&mut self, value: Option<crate::protocol::CodeHighlighting>) {
1902 self.code_highlighting = value;
1903 }
1904 fn code_highlighting(&self) -> Option<&crate::protocol::CodeHighlighting> {
1905 self.code_highlighting.as_ref()
1906 }
1907}
1908
1909impl crate::protocol::ConsoleRegions for Console {
1910 fn set_region_sink(&mut self, value: Option<std::sync::Arc<dyn crate::protocol::RegionSink>>) {
1911 self.region_sink = value;
1912 }
1913 fn region_sink(&self) -> Option<&dyn crate::protocol::RegionSink> {
1914 self.region_sink.as_deref()
1915 }
1916}
1917
1918impl crate::protocol::ConsoleEnvironment for Console {
1919 fn set_render_environment(
1920 &mut self,
1921 value: Option<std::sync::Arc<dyn crate::protocol::RenderEnvironment>>,
1922 ) {
1923 self.render_environment = value;
1924 }
1925 fn render_environment(&self) -> Option<&dyn crate::protocol::RenderEnvironment> {
1926 self.render_environment.as_deref()
1927 }
1928}
1929
1930#[cfg(test)]
1931mod tests {
1932 use super::*;
1933
1934 fn test_console() -> Console {
1935 Console::builder()
1936 .force_terminal(true)
1937 .color_system(Some(ColorSystem::Truecolor))
1938 .width(80)
1939 .no_color(false)
1940 .build()
1941 }
1942
1943 /// The strict path reports malformed markup where the lenient one prints it
1944 /// literally. Both must still agree on markup that is actually valid.
1945 #[test]
1946 fn empty_text_and_empty_renderables_have_distinct_endings() {
1947 let console = Console::builder().force_terminal(false).build();
1948 assert_eq!(console.render_export(&Text::new("")), "\n");
1949 #[cfg(feature = "markdown")]
1950 assert_eq!(
1951 console.render_export(&crate::markdown::Markdown::new("")),
1952 ""
1953 );
1954 assert_eq!(console.render_export(&crate::table::Table::new()), "\n");
1955 }
1956
1957 #[test]
1958 fn try_build_text_reports_bad_markup() {
1959 let console = test_console();
1960
1961 let err = console
1962 .try_build_text("[/nope]")
1963 .expect_err("an unmatched closing tag must be an error");
1964 assert!(
1965 matches!(err, crate::errors::RichError::Markup(_)),
1966 "{err:?}"
1967 );
1968 // The lenient path swallows it and prints the source text as-is.
1969 assert_eq!(console.build_text("[/nope]").plain(), "[/nope]");
1970
1971 let strict = console.try_build_text("[bold]hi[/]").expect("valid markup");
1972 assert_eq!(strict.plain(), "hi");
1973 assert_eq!(
1974 strict.spans().len(),
1975 console.build_text("[bold]hi[/]").spans().len()
1976 );
1977 }
1978
1979 /// An unknown tag *name* is not an error — it renders as a no-op, tag
1980 /// consumed. Only genuine syntax errors fail.
1981 ///
1982 /// Verified against real rich 15.0.0: `Console().print("[nope]x[/]")` writes
1983 /// `x`, while `[bold]a[/italic]` raises `MarkupError`. Before names were
1984 /// carried on spans, the port resolved `nope` eagerly, failed, and fell back
1985 /// to printing the markup source literally.
1986 #[test]
1987 fn unknown_tag_names_render_as_no_ops() {
1988 let console = test_console();
1989 let text = console
1990 .try_build_text("[nope]x[/]")
1991 .expect("an unknown tag name is not a syntax error");
1992 assert_eq!(console.render_to_string(&text), "x");
1993 assert_eq!(
1994 console.render_to_string(&console.build_text("[a.b.c]x[/]")),
1995 "x"
1996 );
1997
1998 // A mismatched closing tag is still an error, on both paths.
1999 assert!(console.try_build_text("[bold]a[/italic]").is_err());
2000 assert!(console.try_build_text("[/nope]").is_err());
2001 }
2002
2003 /// Markup styles bind to the theme of the console that renders the text, not
2004 /// the one that parsed it. Verified against real rich 15.0.0.
2005 #[test]
2006 fn markup_styles_bind_at_render_not_at_parse() {
2007 let themed = |definition: &str| {
2008 let mut theme = Theme::default_theme();
2009 theme.insert("accent", Style::parse(definition).unwrap());
2010 Console::builder()
2011 .force_terminal(true)
2012 .color_system(Some(ColorSystem::Truecolor))
2013 .width(80)
2014 .no_color(false)
2015 .theme(theme)
2016 .build()
2017 };
2018 let red = themed("bold red");
2019 let green = themed("underline green");
2020
2021 // Built once, by the red console...
2022 let text = red.build_text("[accent]hi[/]");
2023 assert_eq!(red.render_to_string(&text), "\x1b[1;31mhi\x1b[0m");
2024 // ...and the green console still renders it in green.
2025 assert_eq!(green.render_to_string(&text), "\x1b[4;32mhi\x1b[0m");
2026 }
2027
2028 /// Emoji expansion and the highlighters have to run on both paths, or the
2029 /// strict variant would quietly render differently from the lenient one.
2030 #[test]
2031 fn try_build_text_expands_emoji_like_build_text() {
2032 let console = test_console();
2033 assert_eq!(
2034 console
2035 .try_build_text(":rocket: go")
2036 .expect("valid")
2037 .plain(),
2038 console.build_text(":rocket: go").plain()
2039 );
2040 }
2041
2042 #[test]
2043 fn renders_markup_string() {
2044 let console = test_console();
2045 assert_eq!(
2046 console.render_str_to_string("[bold red]hi[/]"),
2047 "\x1b[1;31mhi\x1b[0m"
2048 );
2049 }
2050
2051 #[test]
2052 fn print_justify_pads_to_width() {
2053 let console = Console::builder()
2054 .force_terminal(true)
2055 .color_system(Some(ColorSystem::Truecolor))
2056 .width(10)
2057 .build();
2058 // Captured from real rich 15.0.0: console.print("hi", justify=...).
2059 assert_eq!(
2060 console.render_justified_to_string("hi", Justify::Left),
2061 "hi "
2062 );
2063 assert_eq!(
2064 console.render_justified_to_string("hi", Justify::Center),
2065 " hi "
2066 );
2067 assert_eq!(
2068 console.render_justified_to_string("hi", Justify::Right),
2069 " hi"
2070 );
2071 }
2072
2073 #[test]
2074 fn capture_records_ansi_instead_of_stdout() {
2075 let console = Console::builder()
2076 .force_terminal(true)
2077 .color_system(Some(ColorSystem::Truecolor))
2078 .width(20)
2079 .build();
2080 // Captured from real rich 15.0.0 (Console.capture()).
2081 let out = console.capture(|c| c.print_str("[bold red]hi[/] there"));
2082 assert_eq!(out, "\x1b[1;31mhi\x1b[0m there\n");
2083 }
2084
2085 #[test]
2086 fn themed_exports_use_the_given_palette() {
2087 use crate::terminal_theme::{MONOKAI, NIGHT_OWLISH};
2088
2089 let console = Console::builder()
2090 .force_terminal(true)
2091 .color_system(Some(ColorSystem::Truecolor))
2092 .width(20)
2093 .no_color(false)
2094 .build();
2095 let render = |c: &Console| c.print_str("hi");
2096
2097 // Monokai's background is #0c0c0c and Night Owlish's is #ffffff, so the
2098 // chosen theme has to show up in the emitted CSS.
2099 let monokai = console.export_html_themed(&MONOKAI, render);
2100 assert!(
2101 monokai.contains("#0c0c0c"),
2102 "monokai bg missing:\n{monokai}"
2103 );
2104
2105 let owlish = console.export_html_themed(&NIGHT_OWLISH, render);
2106 assert!(owlish.contains("#ffffff"), "owlish bg missing:\n{owlish}");
2107 assert!(!owlish.contains("#0c0c0c"), "leaked monokai into owlish");
2108
2109 // The class form and SVG take a theme too.
2110 let classes = console.export_html_classes_themed(&MONOKAI, render);
2111 assert!(classes.contains("#0c0c0c"), "class-form ignored the theme");
2112 let svg = console.export_svg_themed(&MONOKAI, "t", "id", render);
2113 assert!(svg.contains("#0c0c0c"), "svg ignored the theme");
2114
2115 // The convenience methods keep their documented defaults.
2116 assert!(console.export_html(render).contains("#ffffff"));
2117 }
2118
2119 #[test]
2120 fn page_with_honors_the_styles_flag() {
2121 use std::sync::Mutex;
2122
2123 #[derive(Default)]
2124 struct Recorder(Mutex<String>);
2125 impl crate::pager::Pager for Recorder {
2126 fn show(&self, content: &str) -> std::io::Result<()> {
2127 *self.0.lock().unwrap() = content.to_string();
2128 Ok(())
2129 }
2130 }
2131
2132 let console = Console::builder()
2133 .force_terminal(true)
2134 .color_system(Some(ColorSystem::Truecolor))
2135 .width(20)
2136 .no_color(false)
2137 .build();
2138
2139 // styles = false (upstream's `Console.pager()` default) strips ANSI.
2140 let plain = Recorder::default();
2141 console
2142 .page_with(&plain, false, |c| c.print_str("[bold red]hi[/] there"))
2143 .unwrap();
2144 assert_eq!(plain.0.lock().unwrap().as_str(), "hi there\n");
2145
2146 // styles = true keeps it, matching `Console.pager(styles=True)`.
2147 let styled = Recorder::default();
2148 console
2149 .page_with(&styled, true, |c| c.print_str("[bold red]hi[/] there"))
2150 .unwrap();
2151 assert_eq!(
2152 styled.0.lock().unwrap().as_str(),
2153 "\x1b[1;31mhi\x1b[0m there\n"
2154 );
2155 }
2156
2157 #[test]
2158 fn export_text_strips_styles() {
2159 let console = Console::builder()
2160 .force_terminal(true)
2161 .color_system(Some(ColorSystem::Truecolor))
2162 .width(20)
2163 .build();
2164 // Captured from real rich 15.0.0 (Console.export_text(styles=False)).
2165 let out = console.export_text(|c| c.print_str("[bold red]hi[/] there"));
2166 assert_eq!(out, "hi there\n");
2167 }
2168
2169 #[test]
2170 fn export_html_matches_upstream() {
2171 let console = Console::builder()
2172 .force_terminal(true)
2173 .color_system(Some(ColorSystem::Truecolor))
2174 .width(20)
2175 .no_color(false)
2176 .build();
2177 let html = console.export_html(|c| {
2178 c.print_str("[bold red]hi[/] there");
2179 c.print_str("plain line");
2180 });
2181 // Regenerated from real rich by `scripts/capture_golden.py`, so CI's
2182 // drift check covers exports too. Keep this input in step with the
2183 // matching console in that script.
2184 let expected = include_str!("../tests/golden/export_html.html").replace("\r\n", "\n");
2185 assert_eq!(html, expected);
2186 }
2187
2188 #[test]
2189 fn export_html_classes_matches_upstream() {
2190 let console = Console::builder()
2191 .force_terminal(true)
2192 .color_system(Some(ColorSystem::Truecolor))
2193 .width(20)
2194 .no_color(false)
2195 .build();
2196 let html = console.export_html_classes(|c| c.print_str("[bold red]hi[/] there"));
2197 // As above: regenerated by `scripts/capture_golden.py`. Note this test
2198 // prints ONE line where the inline-styles test prints two.
2199 let expected =
2200 include_str!("../tests/golden/export_html_classes.html").replace("\r\n", "\n");
2201 assert_eq!(html, expected);
2202 }
2203
2204 /// Upstream's `render_lines` pads with its `style` argument as given
2205 /// (default `None`), so the pad segments carry no style.
2206 #[test]
2207 fn render_lines_pads_with_the_given_style() {
2208 let console = test_console();
2209 let options = console.options().update_width(5).update_height(2);
2210 let lines = console.render_lines(&Text::new("hi"), &options, true);
2211 assert_eq!(lines.len(), 2);
2212 let width_pad = lines[0].last().unwrap();
2213 assert_eq!((width_pad.text.as_str(), &width_pad.style), (" ", &None));
2214 assert_eq!(lines[1], vec![Segment::new(" ", None)]);
2215
2216 let red = Style::parse("red").unwrap();
2217 let lines = console.render_lines_styled(&Text::new("hi"), &options, Some(&red), true);
2218 assert_eq!(lines[0].last().unwrap().style, Some(red.clone()));
2219 assert_eq!(lines[1], vec![Segment::new(" ", Some(red))]);
2220 }
2221
2222 #[test]
2223 fn capture_matches_direct_render() {
2224 let console = test_console();
2225 let panel = crate::panel::Panel::new(Box::new(Text::new("hi")));
2226 assert_eq!(
2227 console.capture(|c| c.print(&panel)),
2228 console.render_export(&panel)
2229 );
2230 }
2231
2232 #[test]
2233 fn no_color_strips_styles() {
2234 let console = Console::builder()
2235 .force_terminal(true)
2236 .color_system(None)
2237 .build();
2238 assert_eq!(console.render_str_to_string("[bold red]hi[/]"), "hi");
2239 }
2240
2241 #[test]
2242 fn used_theme_applies_through_the_guard_and_pops_on_drop() {
2243 let mut console = Console::builder()
2244 .force_terminal(true)
2245 .color_system(Some(ColorSystem::Truecolor))
2246 .width(20)
2247 .highlight(false)
2248 .build();
2249 let theme = Theme::from_styles([("accent", "bold red")], false).unwrap();
2250 {
2251 let themed = console.use_theme(theme);
2252 let out = themed.capture(|c| c.print_str("[accent]x[/]"));
2253 assert_eq!(out, "\x1b[1;31mx\x1b[0m\n");
2254 }
2255 assert!(console.theme().get("accent").is_none());
2256 assert!(console.pop_theme().is_err(), "the base theme must remain");
2257 }
2258
2259 #[test]
2260 fn used_theme_is_popped_during_a_panic_unwind() {
2261 let mut console = Console::builder().width(20).build();
2262 let theme = Theme::from_styles([("accent", "bold")], false).unwrap();
2263 let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2264 let _themed = console.use_theme(theme);
2265 panic!("render failed");
2266 }));
2267 assert!(unwound.is_err());
2268 assert!(console.theme().get("accent").is_none());
2269 }
2270
2271 #[test]
2272 fn a_printed_text_defers_layout_to_the_print_options() {
2273 // Captured from real rich 15.0.0 (#446, #447): `Text.join` drops the
2274 // text's own overflow and justify; print-level options still apply.
2275 let console = Console::builder()
2276 .force_terminal(true)
2277 .color_system(Some(crate::color::ColorSystem::Truecolor))
2278 .width(6)
2279 .highlight(false)
2280 .build();
2281 let text = Text::new("abcdefghij").overflow(Overflow::Ellipsis);
2282 assert_eq!(console.render_export(&text), "abcdef\nghij\n");
2283 let mut options = console.options();
2284 options.overflow = Some(Overflow::Ellipsis);
2285 assert_eq!(
2286 console.render_export_with(&Text::new("abcdefghij"), &options),
2287 "abcde…\n"
2288 );
2289 let wide = Console::builder()
2290 .force_terminal(true)
2291 .color_system(Some(crate::color::ColorSystem::Truecolor))
2292 .width(20)
2293 .highlight(false)
2294 .build();
2295 let tabbed = Text::new("a\tb").justify(Justify::Right);
2296 assert_eq!(wide.render_export(&tabbed), "a b\n");
2297 }
2298}