Skip to main content

ctl_core/
render.rs

1//! Render semantic documents through the ctl-layout widgets.
2//!
3//! A [`Document`] becomes a tree of widgets, one per block:
4//!
5//! - [`Fields`] becomes a record, and [`Table`] a table.
6//! - A [`Section`] becomes its heading over its body.
7//! - A [`Notice`] and every paragraph become styled text.
8//! - A rule stays a rule, and verbatim text keeps its own lines.
9//!
10//! The [`Theme`] turns each [`Role`] into a style on the way, so the widgets
11//! never see a role. The widgets lay the document out as plain lines of styled
12//! spans. Escape sequences are added only when ctl-term's encoder writes the
13//! lines.
14
15use ctl_layout::{
16    Breakpoint, Column, Definitions, Record, Rule as RuleWidget, Stack, Table as TableWidget,
17};
18pub use ctl_layout::{Frame, RecordStyle, RowSeparation};
19use ctl_term::{ColorDepth, Encoder};
20use ctl_text::unsafe_chars::{Keep, strip_unsafe};
21use ctl_text::{
22    Context, Line, Measurement, Overflow, Renderable, Span as StyledSpan, Style, Text as StyledText,
23};
24
25use crate::color::ColorMode;
26use crate::document::{Block, Document, Fields, Notice, NoticeLevel, Role, Section, Table, Text};
27use crate::theme::Theme;
28
29/// Default cells reserved from an automatically detected terminal width.
30#[cfg(any(feature = "view", feature = "help"))]
31pub const DEFAULT_COLUMN_BUFFER: u16 = ctl_term::DEFAULT_COLUMN_BUFFER;
32/// The environment variable that sets the automatic-width buffer, in cells,
33/// when the CLI has not: `CTL_CORE_COLUMN_BUFFER`.
34#[cfg(any(feature = "view", feature = "help"))]
35pub const DEFAULT_COLUMN_BUFFER_ENV: &str = "CTL_CORE_COLUMN_BUFFER";
36/// The ordered list of variables read for the automatic-width buffer, holding
37/// only [`DEFAULT_COLUMN_BUFFER_ENV`].
38#[cfg(any(feature = "view", feature = "help"))]
39pub const DEFAULT_COLUMN_BUFFER_ENVS: &[&str] = &[DEFAULT_COLUMN_BUFFER_ENV];
40/// Default floor for an automatically detected effective width.
41#[cfg(any(feature = "view", feature = "help"))]
42pub const DEFAULT_MINIMUM_AUTOMATIC_WIDTH: u16 = ctl_term::DEFAULT_MINIMUM_WIDTH;
43/// Width used when neither the terminal nor `COLUMNS` gives one.
44pub const DEFAULT_FALLBACK_WIDTH: u16 = ctl_term::DEFAULT_FALLBACK_WIDTH;
45/// The environment variable that sets the record style when the CLI has not:
46/// `CTL_CORE_RECORD_STYLE`. Its values are the [`RecordStyle`] names
47/// `keys-right`, `keys-left`, and `boxed`.
48pub const DEFAULT_RECORD_STYLE_ENV: &str = "CTL_CORE_RECORD_STYLE";
49/// The ordered list of variables read for the record style, holding only
50/// [`DEFAULT_RECORD_STYLE_ENV`].
51pub const DEFAULT_RECORD_STYLE_ENVS: &[&str] = &[DEFAULT_RECORD_STYLE_ENV];
52/// The environment variable that sets the table frame when the CLI has not:
53/// `CTL_CORE_LIST_STYLE`. Its values are the [`Frame`] names `grid`,
54/// `header-rule`, and `plain`.
55pub const DEFAULT_LIST_STYLE_ENV: &str = "CTL_CORE_LIST_STYLE";
56/// The ordered list of variables read for the table frame, holding only
57/// [`DEFAULT_LIST_STYLE_ENV`].
58pub const DEFAULT_LIST_STYLE_ENVS: &[&str] = &[DEFAULT_LIST_STYLE_ENV];
59/// The environment variable that sets the row separation when the CLI has not:
60/// `CTL_CORE_ROW_SEPARATION`. Its values are the [`RowSeparation`] names
61/// `none`, `rule`, and `blank`.
62pub const DEFAULT_ROW_SEPARATION_ENV: &str = "CTL_CORE_ROW_SEPARATION";
63/// The ordered list of variables read for the row separation, holding only
64/// [`DEFAULT_ROW_SEPARATION_ENV`].
65pub const DEFAULT_ROW_SEPARATION_ENVS: &[&str] = &[DEFAULT_ROW_SEPARATION_ENV];
66
67/// The width of a rule in a document laid out at its natural width. A rule has
68/// no natural width of its own, so it would otherwise fill that width.
69const NATURAL_RULE_WIDTH: u16 = 40;
70
71/// A style a user can name in an environment variable.
72pub(crate) trait EnvChoice: Copy + 'static {
73    /// Accepted values, in the order a warning lists them.
74    const VALUES: &'static [(&'static str, Self)];
75}
76
77impl EnvChoice for RecordStyle {
78    const VALUES: &'static [(&'static str, Self)] = &[
79        ("keys-right", Self::KeysRight),
80        ("keys-left", Self::KeysLeft),
81        ("boxed", Self::Boxed),
82    ];
83}
84
85impl EnvChoice for Frame {
86    const VALUES: &'static [(&'static str, Self)] = &[
87        ("grid", Self::Grid),
88        ("header-rule", Self::HeaderRule),
89        ("plain", Self::Plain),
90    ];
91}
92
93impl EnvChoice for RowSeparation {
94    const VALUES: &'static [(&'static str, Self)] = &[
95        ("none", Self::None),
96        ("rule", Self::Rule),
97        ("blank", Self::Blank),
98    ];
99}
100
101/// The first variable in `names` that holds an accepted value.
102pub(crate) fn env_choice<T: EnvChoice>(
103    names: &[&str],
104    value: impl Fn(&str) -> Option<String>,
105) -> Option<T> {
106    names
107        .iter()
108        .filter_map(|name| value(name))
109        .find_map(|raw| parse_choice(&raw))
110}
111
112/// One line per variable in `names` whose value is not accepted.
113#[cfg(feature = "app")]
114pub(crate) fn env_choice_warnings<T: EnvChoice>(
115    names: &[&str],
116    value: impl Fn(&str) -> Option<String>,
117) -> Vec<String> {
118    names
119        .iter()
120        .filter_map(|name| {
121            let raw = value(name)?;
122            parse_choice::<T>(&raw).is_none().then(|| {
123                let accepted = T::VALUES
124                    .iter()
125                    .map(|(value, _)| *value)
126                    .collect::<Vec<_>>()
127                    .join(", ");
128                format!("{name}={raw} is ignored; use one of {accepted}")
129            })
130        })
131        .collect()
132}
133
134/// A set, non-empty environment variable.
135pub(crate) fn process_env(name: &str) -> Option<String> {
136    std::env::var(name).ok().filter(|value| !value.is_empty())
137}
138
139fn parse_choice<T: EnvChoice>(raw: &str) -> Option<T> {
140    T::VALUES
141        .iter()
142        .find(|(value, _)| *value == raw.trim())
143        .map(|(_, choice)| *choice)
144}
145
146/// How a document renders: colour mode, width, theme, and styles.
147///
148/// A setter takes the name of its setting and returns the options changed.
149/// The options have no getters, which would need the same names. A method that
150/// reads the environment starts with `resolved_`.
151#[derive(Clone, Copy, Debug, Eq, PartialEq)]
152pub struct RenderOptions {
153    color: ColorMode,
154    theme: Theme,
155    width: Option<u16>,
156    #[cfg(any(feature = "view", feature = "help"))]
157    automatic_width_buffer: Option<u16>,
158    #[cfg(any(feature = "view", feature = "help"))]
159    automatic_width_buffer_envs: &'static [&'static str],
160    #[cfg(any(feature = "view", feature = "help"))]
161    minimum_automatic_width: u16,
162    fallback_width: Option<u16>,
163    record_style: Option<RecordStyle>,
164    record_style_envs: &'static [&'static str],
165    frame: Option<Frame>,
166    frame_envs: &'static [&'static str],
167    row_separation: Option<RowSeparation>,
168    row_separation_envs: &'static [&'static str],
169}
170
171impl RenderOptions {
172    /// Build options with [`DEFAULT_FALLBACK_WIDTH`] when no width is
173    /// detected. Record style, table frame, and row separation read
174    /// [`DEFAULT_RECORD_STYLE_ENV`], [`DEFAULT_LIST_STYLE_ENV`], and
175    /// [`DEFAULT_ROW_SEPARATION_ENV`] unless the CLI sets them. Roles are
176    /// drawn in [`Theme::default`].
177    ///
178    /// With `view` or `help`, the width is detected for the stream written,
179    /// with a buffer of `DEFAULT_COLUMN_BUFFER` cells and
180    /// `DEFAULT_COLUMN_BUFFER_ENV` as the user's override.
181    #[must_use]
182    pub const fn new(color: ColorMode) -> Self {
183        Self {
184            color,
185            theme: Theme::new(),
186            width: None,
187            #[cfg(any(feature = "view", feature = "help"))]
188            automatic_width_buffer: None,
189            #[cfg(any(feature = "view", feature = "help"))]
190            automatic_width_buffer_envs: DEFAULT_COLUMN_BUFFER_ENVS,
191            #[cfg(any(feature = "view", feature = "help"))]
192            minimum_automatic_width: DEFAULT_MINIMUM_AUTOMATIC_WIDTH,
193            fallback_width: Some(DEFAULT_FALLBACK_WIDTH),
194            record_style: None,
195            record_style_envs: DEFAULT_RECORD_STYLE_ENVS,
196            frame: None,
197            frame_envs: DEFAULT_LIST_STYLE_ENVS,
198            row_separation: None,
199            row_separation_envs: DEFAULT_ROW_SEPARATION_ENVS,
200        }
201    }
202
203    /// Write colour as `color` says, in place of the mode given to
204    /// [`Self::new`].
205    #[must_use]
206    pub const fn color(mut self, color: ColorMode) -> Self {
207        self.color = color;
208        self
209    }
210
211    /// Draw each [`Role`] in the style `theme` gives it, in place of
212    /// [`Theme::default`]. Colour is still the colour mode's choice:
213    /// [`ColorMode::Never`] writes no style.
214    #[must_use]
215    pub const fn theme(mut self, theme: Theme) -> Self {
216        self.theme = theme;
217        self
218    }
219
220    /// Frame [`Fields`] records in `style`. This beats the environment.
221    #[must_use]
222    pub const fn record_style(mut self, style: RecordStyle) -> Self {
223        self.record_style = Some(style);
224        self
225    }
226
227    /// Set the frame every [`Table`] draws to `frame`. This beats the
228    /// environment.
229    #[must_use]
230    pub const fn frame(mut self, frame: Frame) -> Self {
231        self.frame = Some(frame);
232        self
233    }
234
235    /// Separate [`Table`] rows with `separation`. This beats the environment.
236    #[must_use]
237    pub const fn row_separation(mut self, separation: RowSeparation) -> Self {
238        self.row_separation = Some(separation);
239        self
240    }
241
242    /// Replace the ordered environment names read for the record style.
243    /// An empty slice disables lookup.
244    #[must_use]
245    pub const fn record_style_envs(mut self, names: &'static [&'static str]) -> Self {
246        self.record_style_envs = names;
247        self
248    }
249
250    /// Replace the ordered environment names read for the table frame.
251    /// An empty slice disables lookup.
252    #[must_use]
253    pub const fn frame_envs(mut self, names: &'static [&'static str]) -> Self {
254        self.frame_envs = names;
255        self
256    }
257
258    /// Replace the ordered environment names read for the row separation.
259    /// An empty slice disables lookup.
260    #[must_use]
261    pub const fn row_separation_envs(mut self, names: &'static [&'static str]) -> Self {
262        self.row_separation_envs = names;
263        self
264    }
265
266    /// Force an explicit width. Use it in tests and wherever no terminal
267    /// decides the width.
268    #[must_use]
269    pub const fn width(mut self, width: u16) -> Self {
270        self.width = Some(width);
271        self
272    }
273
274    /// Override the automatic-width buffer. Zero explicitly disables it.
275    /// Explicit [`Self::width`] remains exact regardless of this value.
276    #[cfg(any(feature = "view", feature = "help"))]
277    #[must_use]
278    pub const fn automatic_width_buffer(mut self, cells: u16) -> Self {
279        self.automatic_width_buffer = Some(cells);
280        self
281    }
282
283    /// Replace the ordered environment names that set the buffer.
284    /// Pass aliases in precedence order or an empty slice to disable lookup.
285    #[cfg(any(feature = "view", feature = "help"))]
286    #[must_use]
287    pub const fn automatic_width_buffer_envs(mut self, names: &'static [&'static str]) -> Self {
288        self.automatic_width_buffer_envs = names;
289        self
290    }
291
292    /// Set the floor for automatically detected effective widths.
293    #[cfg(any(feature = "view", feature = "help"))]
294    #[must_use]
295    pub const fn minimum_automatic_width(mut self, cells: u16) -> Self {
296        self.minimum_automatic_width = cells;
297        self
298    }
299
300    /// Lay out to `width` when neither the terminal nor `COLUMNS` gives one.
301    /// `None` renders tables at their natural width instead. This does not
302    /// touch the minimum automatic width, which applies only to a detected
303    /// width.
304    #[must_use]
305    pub const fn fallback_width(mut self, width: Option<u16>) -> Self {
306        self.fallback_width = width;
307        self
308    }
309
310    /// Take the theme, record style, table frame, row separation, and the
311    /// variables they read from `styles`. Colour and width stay as they are.
312    #[cfg(feature = "view")]
313    #[must_use]
314    pub(crate) const fn styles(mut self, styles: Self) -> Self {
315        self.theme = styles.theme;
316        self.record_style = styles.record_style;
317        self.record_style_envs = styles.record_style_envs;
318        self.frame = styles.frame;
319        self.frame_envs = styles.frame_envs;
320        self.row_separation = styles.row_separation;
321        self.row_separation_envs = styles.row_separation_envs;
322        self
323    }
324
325    /// Record framing: the CLI's choice, then the environment, then the
326    /// default. It reads the process environment.
327    #[must_use]
328    pub fn resolved_record_style(self) -> RecordStyle {
329        self.record_style_with(process_env)
330    }
331
332    /// Table frame: the CLI's choice, then the environment, then the
333    /// default. It reads the process environment.
334    #[must_use]
335    pub fn resolved_frame(self) -> Frame {
336        self.frame_with(process_env)
337    }
338
339    /// Row separation: the CLI's choice, then the environment, then the
340    /// default. It reads the process environment.
341    #[must_use]
342    pub fn resolved_row_separation(self) -> RowSeparation {
343        self.row_separation_with(process_env)
344    }
345
346    fn record_style_with(self, value: impl Fn(&str) -> Option<String>) -> RecordStyle {
347        self.record_style
348            .or_else(|| env_choice(self.record_style_envs, value))
349            .unwrap_or_default()
350    }
351
352    fn frame_with(self, value: impl Fn(&str) -> Option<String>) -> Frame {
353        self.frame
354            .or_else(|| env_choice(self.frame_envs, value))
355            .unwrap_or_default()
356    }
357
358    fn row_separation_with(self, value: impl Fn(&str) -> Option<String>) -> RowSeparation {
359        self.row_separation
360            .or_else(|| env_choice(self.row_separation_envs, value))
361            .unwrap_or_default()
362    }
363
364    /// One line per style variable whose value is not accepted. A style the
365    /// CLI set never reads its variables, so it never warns.
366    #[cfg(feature = "app")]
367    pub(crate) fn style_env_warnings(self, value: impl Fn(&str) -> Option<String>) -> Vec<String> {
368        let mut warnings = Vec::new();
369        if self.record_style.is_none() {
370            warnings.extend(env_choice_warnings::<RecordStyle>(
371                self.record_style_envs,
372                &value,
373            ));
374        }
375        if self.frame.is_none() {
376            warnings.extend(env_choice_warnings::<Frame>(self.frame_envs, &value));
377        }
378        if self.row_separation.is_none() {
379            warnings.extend(env_choice_warnings::<RowSeparation>(
380                self.row_separation_envs,
381                &value,
382            ));
383        }
384        warnings
385    }
386
387    /// The width policy as ctl-term's detection options: the explicit width,
388    /// the buffer and its variables, the minimum, and the fallback.
389    #[cfg(any(feature = "view", feature = "help"))]
390    const fn detect_options(self) -> ctl_term::DetectOptions {
391        let mut options = ctl_term::DetectOptions::new()
392            .column_buffer_envs(self.automatic_width_buffer_envs)
393            .minimum_width(self.minimum_automatic_width)
394            .fallback_width(self.fallback_width);
395        if let Some(width) = self.width {
396            options = options.width(width);
397        }
398        if let Some(buffer) = self.automatic_width_buffer {
399            options = options.column_buffer(buffer);
400        }
401        options
402    }
403
404    /// A writer for `stream` that decides colour and width for that stream:
405    /// this colour mode, the process environment, and [`Self::detect_options`].
406    #[cfg(any(feature = "view", feature = "help"))]
407    fn console(self, stream: ctl_term::Stream) -> ctl_term::Console {
408        ctl_term::Console::from_process(stream, self.color.into(), self.detect_options())
409    }
410
411    /// `document` as text in the colour and at the width decided for `stream`.
412    #[cfg(feature = "view")]
413    pub(crate) fn render_for(self, stream: ctl_term::Stream, document: &Document) -> String {
414        self.console(stream)
415            .render(&Renderer::new(self).widget(document))
416    }
417
418    /// Write `document` to `stream` in the colour and at the width decided for
419    /// it.
420    ///
421    /// The error keeps the kind of the failed write, so a closed pipe is still
422    /// [`std::io::ErrorKind::BrokenPipe`]. It goes through ctl-term's
423    /// conversion rather than a boxed wrapper, which would keep the wrapper's
424    /// `Debug` code in every binary that prints help.
425    #[cfg(feature = "help")]
426    pub(crate) fn print_for(
427        self,
428        stream: ctl_term::Stream,
429        document: &Document,
430    ) -> std::io::Result<()> {
431        self.console(stream)
432            .print(&Renderer::new(self).widget(document))
433            .map_err(std::io::Error::from)
434    }
435}
436
437/// Semantic document renderer.
438#[derive(Clone, Copy, Debug, Eq, PartialEq)]
439pub(crate) struct Renderer {
440    options: RenderOptions,
441}
442
443impl Renderer {
444    /// Build a renderer.
445    pub(crate) const fn new(options: RenderOptions) -> Self {
446        Self { options }
447    }
448
449    /// Render one document to a newline-terminated string, as
450    /// [`Document::render`] says.
451    pub(crate) fn render(self, document: &Document) -> String {
452        let context = self
453            .options
454            .width
455            .or(self.options.fallback_width)
456            .map_or_else(Context::unbounded, Context::new);
457        let lines = self.widget(document).render(&context);
458        let depth = (self.options.color == ColorMode::Always).then_some(ColorDepth::TrueColor);
459        Encoder::new(depth).encode(&lines)
460    }
461
462    /// The document as one widget, with every role resolved through the
463    /// theme. The caller picks the width and writes the lines.
464    pub(crate) fn widget(self, document: &Document) -> Stack<'static> {
465        document
466            .blocks()
467            .iter()
468            .fold(Stack::new().spacing(1), |stack, block| {
469                stack.push(self.block(block))
470            })
471    }
472
473    fn block(self, block: &Block) -> Box<dyn Renderable> {
474        match block {
475            Block::Heading(text) => Box::new(self.text(text, Some(Role::Heading))),
476            Block::Paragraph(text) => Box::new(self.text(text, None)),
477            Block::Verbatim(value) => Box::new(Verbatim::new(value)),
478            Block::Fields(fields) => Box::new(self.fields(fields)),
479            Block::Table(table) => self.table(table),
480            Block::Section(section) => Box::new(self.section(section)),
481            Block::Notice(notice) => Box::new(self.notice(notice)),
482            Block::Rule(rule) => Box::new(Divider(
483                rule.title().map_or_else(RuleWidget::new, |title| {
484                    RuleWidget::titled(self.text(title, Some(Role::Heading)))
485                }),
486            )),
487        }
488    }
489
490    /// The heading directly over the body. There is no blank line between them
491    /// and no indent, so a section's tables line up with the document's.
492    fn section(self, section: &Section) -> Stack<'static> {
493        Stack::new()
494            .push(self.text(section.title(), Some(Role::Heading)))
495            .push(self.widget(section.body()))
496    }
497
498    fn fields(self, fields: &Fields) -> Record {
499        fields.rows().iter().fold(
500            Record::new().style(self.options.resolved_record_style()),
501            |record, (label, value)| record.row(self.text(label, None), self.text(value, None)),
502        )
503    }
504
505    fn table(self, table: &Table) -> Box<dyn Renderable> {
506        let count = table
507            .rows()
508            .iter()
509            .map(Vec::len)
510            .fold(table.headers().len(), usize::max);
511        let grid = TableWidget::new((0..count).map(|index| self.column(table, index)))
512            .frame(self.options.resolved_frame())
513            .row_separation(self.options.resolved_row_separation())
514            .rows(table.rows().iter().map(|row| {
515                row.iter()
516                    .enumerate()
517                    .map(|(index, cell)| self.text(cell, cell_role(table, index)))
518                    .collect::<Vec<_>>()
519            }));
520        let Some(policy) = table.stacked() else {
521            return Box::new(grid);
522        };
523        let definitions = table
524            .rows()
525            .iter()
526            .fold(Definitions::new(), |definitions, row| {
527                let (labels, description) = row.split_at(row.len().min(policy.label_columns()));
528                definitions.entry(
529                    labels.iter().map(|cell| self.text(cell, Some(Role::Token))),
530                    description.iter().map(|cell| self.text(cell, None)),
531                )
532            });
533        Box::new(Breakpoint::new(policy.width(), definitions, grid))
534    }
535
536    fn column(self, table: &Table, index: usize) -> Column {
537        let column = if table.headers().is_empty() {
538            Column::untitled()
539        } else {
540            Column::new(
541                table
542                    .headers()
543                    .get(index)
544                    .map_or_else(StyledText::new, |header| {
545                        self.text(header, Some(Role::Heading))
546                    }),
547            )
548        };
549        let column = if table.ellipsis_columns().contains(&index) {
550            column.overflow(Overflow::Ellipsis)
551        } else {
552            column
553        };
554        if table.numeric_columns().contains(&index) {
555            column.numeric()
556        } else {
557            column
558        }
559    }
560
561    fn notice(self, notice: &Notice) -> StyledText {
562        let theme = self.options.theme;
563        let label = match notice.level() {
564            NoticeLevel::Success => "success",
565            NoticeLevel::Warning => "warning",
566            NoticeLevel::Error => "error",
567        };
568        let separator = theme.role_style(Role::Plain);
569        let mut text = StyledText::styled(label, theme.label_style(notice.level()));
570        if let Some(code) = notice.code_value() {
571            text.push(" · ", separator);
572            text.push(code, theme.role_style(Role::Muted));
573        }
574        text.push(" · ", separator);
575        self.push_spans(&mut text, notice.message(), None);
576        text
577    }
578
579    /// `text` with each span in its role's style. A plain span takes
580    /// `default` when one is given, so a heading or a token column styles
581    /// the text a CLI left plain.
582    fn text(self, text: &Text, default: Option<Role>) -> StyledText {
583        let mut styled = StyledText::new();
584        self.push_spans(&mut styled, text, default);
585        styled
586    }
587
588    fn push_spans(self, target: &mut StyledText, text: &Text, default: Option<Role>) {
589        let theme = self.options.theme;
590        for span in text.spans() {
591            let role = match (span.role(), default) {
592                (Role::Plain, Some(default)) => default,
593                (role, _) => role,
594            };
595            target.push(span.value(), theme.role_style(role));
596        }
597    }
598}
599
600/// The role a table cell's plain spans take: a token column's, then an id
601/// column's, then none.
602fn cell_role(table: &Table, index: usize) -> Option<Role> {
603    if table.token_column_index() == Some(index) {
604        Some(Role::Token)
605    } else if table.id_column_index() == Some(index) {
606        Some(Role::Id)
607    } else {
608        None
609    }
610}
611
612impl Document {
613    /// Render with explicit semantic options; see [`RenderOptions`].
614    ///
615    /// The string is a pure function of the document and the options: no
616    /// terminal is asked. The document lays out to the explicit width, else
617    /// the fallback width, else its natural width.
618    ///
619    /// Styles are written only under [`ColorMode::Always`], as the theme gives
620    /// them, at full colour depth. [`ColorMode::Auto`] has no stream to look
621    /// at here and writes none. A [`View`] and help write to a stream, so they
622    /// decide width and colour for it instead.
623    #[cfg_attr(feature = "view", doc = "", doc = "[`View`]: crate::View")]
624    #[cfg_attr(
625        not(feature = "view"),
626        doc = "",
627        doc = "[`View`]: crate#cargo-features"
628    )]
629    #[must_use]
630    pub fn render(&self, options: RenderOptions) -> String {
631        Renderer::new(options).render(self)
632    }
633}
634
635/// Preformatted lines, drawn as given at any width.
636///
637/// The unsafe characters go, newline and tab aside, and so do the newlines at
638/// the end. A tab expands to the next multiple of 8 cells from the start of
639/// its line, since a span holds no tab. Trailing spaces stay.
640#[derive(Debug)]
641struct Verbatim {
642    lines: Vec<Line>,
643}
644
645impl Verbatim {
646    fn new(value: &str) -> Self {
647        let kept = strip_unsafe(value, Keep::NewlineAndTab);
648        let kept = kept.trim_end_matches('\n');
649        let lines = if kept.is_empty() {
650            Vec::new()
651        } else {
652            kept.split('\n')
653                .map(|line| Line::from(StyledSpan::new(line, Style::new())))
654                .collect()
655        };
656        Self { lines }
657    }
658}
659
660impl Renderable for Verbatim {
661    fn measure(&self, _: &Context) -> Measurement {
662        Measurement::exact(self.lines.iter().map(Line::width).max().unwrap_or(0))
663    }
664
665    fn render(&self, _: &Context) -> Vec<Line> {
666        self.lines.clone()
667    }
668}
669
670/// A rule across the width, or across [`NATURAL_RULE_WIDTH`] cells in an
671/// unbounded context, which [`Context::is_unbounded`] reports. A rule has no
672/// natural width, so it would otherwise fill the largest width.
673#[derive(Debug)]
674struct Divider(RuleWidget);
675
676impl Divider {
677    fn context(context: &Context) -> Context {
678        if context.is_unbounded() {
679            context.at_width(NATURAL_RULE_WIDTH)
680        } else {
681            context.clone()
682        }
683    }
684}
685
686impl Renderable for Divider {
687    fn measure(&self, context: &Context) -> Measurement {
688        self.0.measure(&Self::context(context))
689    }
690
691    fn render(&self, context: &Context) -> Vec<Line> {
692        self.0.render(&Self::context(context))
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use ctl_text::Style;
699    use ctl_text::anstyle::Effects;
700    use indoc::{formatdoc, indoc};
701
702    use super::{Frame, RecordStyle, RenderOptions, RowSeparation};
703    use crate::color::ColorMode;
704    use crate::document::{Document, Fields, Notice, NoticeLevel, Role, Section, Table, Text};
705    use crate::theme::Theme;
706
707    #[cfg(any(feature = "view", feature = "help"))]
708    #[test]
709    fn automatic_width_policy_is_explicit_and_overridable() {
710        let defaults = RenderOptions::new(ColorMode::Never);
711        assert_eq!(super::DEFAULT_COLUMN_BUFFER, 2);
712        assert_eq!(defaults.automatic_width_buffer, None);
713        assert_eq!(
714            defaults.minimum_automatic_width,
715            super::DEFAULT_MINIMUM_AUTOMATIC_WIDTH
716        );
717
718        let configured = defaults
719            .automatic_width_buffer(0)
720            .automatic_width_buffer_envs(&["APP_COLUMNS_BUFFER", "LEGACY_BUFFER"])
721            .minimum_automatic_width(8);
722        assert_eq!(configured.automatic_width_buffer, Some(0));
723        assert_eq!(
724            configured.automatic_width_buffer_envs,
725            ["APP_COLUMNS_BUFFER", "LEGACY_BUFFER"]
726        );
727        assert_eq!(configured.minimum_automatic_width, 8);
728    }
729
730    #[cfg(any(feature = "view", feature = "help"))]
731    #[test]
732    fn explicit_width_ignores_the_automatic_buffer() {
733        let document = Document::new().paragraph("one two three four five six");
734        let exact = document.render(RenderOptions::new(ColorMode::Never).width(16));
735        let buffered = document.render(
736            RenderOptions::new(ColorMode::Never)
737                .width(16)
738                .automatic_width_buffer(8),
739        );
740        assert_eq!(buffered, exact);
741    }
742
743    #[test]
744    fn colorless_document_is_deterministic() {
745        let document = Document::new()
746            .heading("status")
747            .fields(Fields::new().row("pending", Text::plain("2")))
748            .notice(Notice::new(NoticeLevel::Warning, "one stale row"));
749        let rendered = document.render(RenderOptions::new(ColorMode::Never).width(60));
750        let expected = indoc! {"
751            status
752
753            pending  2
754
755            warning · one stale row
756        "};
757        assert_eq!(rendered, expected);
758        assert!(!rendered.contains('\u{1b}'));
759    }
760
761    fn queue() -> Table {
762        Table::new(["id", "title"])
763            .row(["task-014", "Let a ledger declare row separation"])
764            .row(["task-015", "Write a ledger atomically"])
765            .row(["task-016", "Name the next startable row"])
766    }
767
768    #[test]
769    fn borderless_record_right_aligns_keys() {
770        let rendered = Document::new()
771            .fields(
772                Fields::new()
773                    .row("ledger", Text::plain("tasks.yaml"))
774                    .row("active", Text::plain("task-014")),
775            )
776            .render(
777                RenderOptions::new(ColorMode::Never)
778                    .width(80)
779                    .record_style(RecordStyle::KeysRight),
780            );
781        let expected = indoc! {"
782            ledger  tasks.yaml
783            active  task-014
784        "};
785        assert_eq!(rendered, expected);
786    }
787
788    #[test]
789    fn left_aligned_record_pads_short_keys_on_the_right() {
790        let rendered = Document::new()
791            .fields(
792                Fields::new()
793                    .row("id", Text::plain("task-014"))
794                    .row("outcome", Text::plain("done")),
795            )
796            .render(
797                RenderOptions::new(ColorMode::Never)
798                    .width(80)
799                    .record_style(RecordStyle::KeysLeft),
800            );
801        let expected = indoc! {"
802            id       task-014
803            outcome  done
804        "};
805        assert_eq!(rendered, expected);
806    }
807
808    #[test]
809    fn header_rule_draws_one_rule_and_no_verticals() {
810        let rendered = Document::new().table(queue()).render(
811            RenderOptions::new(ColorMode::Never)
812                .width(80)
813                .frame(Frame::HeaderRule),
814        );
815        let rules = rendered
816            .lines()
817            .filter(|line| line.starts_with('─'))
818            .count();
819        assert_eq!(rules, 1, "{rendered}");
820        assert_eq!(
821            rendered.lines().nth(1).map(|line| line.starts_with('─')),
822            Some(true)
823        );
824        assert!(!rendered.contains(['│', '┆', '┌', '└']), "{rendered}");
825    }
826
827    #[test]
828    fn rule_separation_draws_a_rule_between_rows() {
829        let rendered = Document::new().table(queue()).render(
830            RenderOptions::new(ColorMode::Never)
831                .width(80)
832                .frame(Frame::Plain)
833                .row_separation(RowSeparation::Rule),
834        );
835        let lines = rendered.lines().collect::<Vec<_>>();
836        assert_eq!(lines.len(), 6, "{rendered}");
837        assert!(
838            lines[2].starts_with('─') && lines[4].starts_with('─'),
839            "{rendered}"
840        );
841    }
842
843    #[test]
844    fn blank_separation_leaves_one_empty_line_between_rows() {
845        let rendered = Document::new().table(queue()).render(
846            RenderOptions::new(ColorMode::Never)
847                .width(80)
848                .frame(Frame::Plain)
849                .row_separation(RowSeparation::Blank),
850        );
851        let lines = rendered.lines().collect::<Vec<_>>();
852        assert_eq!(lines.len(), 6, "{rendered}");
853        assert!(lines[2].is_empty() && lines[4].is_empty(), "{rendered}");
854    }
855
856    #[test]
857    fn defaults_are_the_operator_picks() {
858        let options = RenderOptions::new(ColorMode::Never);
859        assert_eq!(options.resolved_record_style(), RecordStyle::KeysRight);
860        assert_eq!(options.resolved_frame(), Frame::Grid);
861        assert_eq!(options.resolved_row_separation(), RowSeparation::None);
862        assert_eq!(options.fallback_width, Some(80));
863        assert_eq!(options.fallback_width(None).fallback_width, None);
864    }
865
866    fn operator_env(name: &str) -> Option<String> {
867        match name {
868            "CTL_CORE_RECORD_STYLE" => Some("boxed"),
869            "CTL_CORE_LIST_STYLE" => Some("header-rule"),
870            "CTL_CORE_ROW_SEPARATION" => Some("blank"),
871            _ => None,
872        }
873        .map(str::to_owned)
874    }
875
876    #[test]
877    fn the_environment_picks_each_style_the_owner_left_open() {
878        let open = RenderOptions::new(ColorMode::Never);
879        assert_eq!(open.record_style_with(operator_env), RecordStyle::Boxed);
880        assert_eq!(open.frame_with(operator_env), Frame::HeaderRule);
881        assert_eq!(open.row_separation_with(operator_env), RowSeparation::Blank);
882
883        let owned = open
884            .record_style(RecordStyle::KeysLeft)
885            .frame(Frame::Plain)
886            .row_separation(RowSeparation::Rule);
887        assert_eq!(owned.record_style_with(operator_env), RecordStyle::KeysLeft);
888        assert_eq!(owned.frame_with(operator_env), Frame::Plain);
889        assert_eq!(owned.row_separation_with(operator_env), RowSeparation::Rule);
890    }
891
892    #[test]
893    fn style_env_names_can_be_replaced_or_disabled() {
894        let aliased = |name: &str| (name == "TOY_RECORD").then(|| "keys-left".to_owned());
895        let replaced = RenderOptions::new(ColorMode::Never)
896            .record_style_envs(&["TOY_RECORD", "CTL_CORE_RECORD_STYLE"]);
897        assert_eq!(replaced.record_style_with(aliased), RecordStyle::KeysLeft);
898
899        let disabled = RenderOptions::new(ColorMode::Never)
900            .record_style_envs(&[])
901            .frame_envs(&[])
902            .row_separation_envs(&[]);
903        assert_eq!(
904            disabled.record_style_with(operator_env),
905            RecordStyle::KeysRight
906        );
907        assert_eq!(disabled.frame_with(operator_env), Frame::Grid);
908        assert_eq!(
909            disabled.row_separation_with(operator_env),
910            RowSeparation::None
911        );
912    }
913
914    #[cfg(feature = "app")]
915    #[test]
916    fn an_unknown_style_value_is_ignored_and_the_warning_names_the_choices() {
917        let typo = |name: &str| (name == "CTL_CORE_RECORD_STYLE").then(|| "boxy".to_owned());
918        let options = RenderOptions::new(ColorMode::Never);
919        assert_eq!(options.record_style_with(typo), RecordStyle::KeysRight);
920        assert_eq!(
921            options.style_env_warnings(typo),
922            ["CTL_CORE_RECORD_STYLE=boxy is ignored; use one of keys-right, keys-left, boxed"]
923        );
924        assert_eq!(
925            options
926                .record_style(RecordStyle::Boxed)
927                .style_env_warnings(typo),
928            Vec::<String>::new()
929        );
930    }
931
932    #[test]
933    fn colored_document_has_ansi() {
934        let document = Document::new().heading("status");
935        let rendered = document.render(RenderOptions::new(ColorMode::Always).width(60));
936        assert!(rendered.contains('\u{1b}'));
937    }
938
939    #[test]
940    fn a_notice_separator_takes_the_plain_style() {
941        let underline = Style::new().effects(Effects::UNDERLINE);
942        let theme = Theme::new().style(Role::Plain, underline);
943        let rendered = Document::new()
944            .notice(Notice::new(NoticeLevel::Warning, "one stale row"))
945            .render(RenderOptions::new(ColorMode::Always).theme(theme).width(60));
946        assert!(rendered.contains("\u{1b}[4m · "), "{rendered}");
947    }
948
949    #[test]
950    fn ids_are_bold_without_a_colour() {
951        let table = Table::new(["id", "title"])
952            .id_column(0)
953            .row(["task-014", "Title"]);
954        let rendered = Document::new()
955            .table(table)
956            .paragraph(Text::new().id("task-015"))
957            .render(RenderOptions::new(ColorMode::Always).width(60));
958        assert!(
959            rendered.contains("\u{1b}[1mtask-014\u{1b}[0m"),
960            "{rendered}"
961        );
962        assert!(
963            rendered.contains("\u{1b}[1mtask-015\u{1b}[0m"),
964            "{rendered}"
965        );
966    }
967
968    #[test]
969    fn headings_wrap_to_the_width() {
970        let rendered = Document::new()
971            .heading("task-001  a title that runs past the line")
972            .section(Section::new(
973                "a section title that also runs long",
974                Document::new(),
975            ))
976            .render(RenderOptions::new(ColorMode::Never).width(20));
977        assert!(rendered.lines().count() > 2, "{rendered}");
978        assert!(
979            rendered.lines().all(|line| line.chars().count() <= 20),
980            "{rendered}"
981        );
982    }
983
984    #[test]
985    fn long_words_run_past_the_width_instead_of_splitting() {
986        let url = "https://example.test/releases/tag/pkg@1.2.3";
987        let rendered = Document::new()
988            .fields(Fields::new().row("release", Text::plain(url)))
989            .paragraph(Text::plain(format!("see {url}")))
990            .render(RenderOptions::new(ColorMode::Always).width(30));
991        assert_eq!(rendered.matches(url).count(), 2, "{rendered}");
992    }
993
994    #[test]
995    fn narrow_table_stacks() {
996        let table =
997            Table::plain()
998                .stacked_below(64, 2)
999                .row(["-f", "--format", "Output representation"]);
1000        let rendered = Document::new()
1001            .table(table)
1002            .render(RenderOptions::new(ColorMode::Never).width(40));
1003        assert_eq!(rendered, "  -f --format\n    Output representation\n");
1004    }
1005
1006    #[test]
1007    fn verbatim_text_ignores_explicit_width() {
1008        let source = indoc! {"
1009            ```text
1010            this line stays longer than five
1011            ```
1012        "};
1013        let rendered = Document::new()
1014            .verbatim(source)
1015            .render(RenderOptions::new(ColorMode::Never).width(5));
1016        assert_eq!(rendered, source);
1017    }
1018
1019    #[test]
1020    fn verbatim_text_removes_terminal_bidi_and_line_controls() {
1021        let rendered = Document::new()
1022            .verbatim("\u{1b}]52;clipboard\u{7}\u{202e}\u{2028}\u{2029}\tvalue")
1023            .render(RenderOptions::new(ColorMode::Never).width(5));
1024        assert_eq!(rendered, "]52;clipboard   value\n");
1025    }
1026
1027    #[test]
1028    fn verbatim_text_preserves_other_unicode_formatting() {
1029        let source = "\u{600}\u{6dd}\u{70f}\u{110bd}\u{200b}\u{2060}\u{feff}\u{fff9}\u{1d173}\u{e0001}\u{200c}\u{200d}\u{ad}";
1030        let rendered = Document::new()
1031            .verbatim(source)
1032            .render(RenderOptions::new(ColorMode::Never).width(5));
1033        assert_eq!(rendered, format!("{source}\n"));
1034    }
1035
1036    #[test]
1037    fn wrapped_stacked_labels_keep_indentation() {
1038        let table = Table::plain()
1039            .stacked_below(64, 1)
1040            .row(["one two three four five", "description"]);
1041        let rendered = Document::new()
1042            .table(table)
1043            .render(RenderOptions::new(ColorMode::Never).width(14));
1044        let expected = formatdoc! {"
1045            {label}one two
1046            {label}three four
1047            {label}five
1048            {description}description
1049            ",
1050            label = "  ",
1051            description = "    ",
1052        };
1053        assert_eq!(rendered, expected);
1054    }
1055}