1use 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#[cfg(any(feature = "view", feature = "help"))]
31pub const DEFAULT_COLUMN_BUFFER: u16 = ctl_term::DEFAULT_COLUMN_BUFFER;
32#[cfg(any(feature = "view", feature = "help"))]
35pub const DEFAULT_COLUMN_BUFFER_ENV: &str = "CTL_CORE_COLUMN_BUFFER";
36#[cfg(any(feature = "view", feature = "help"))]
39pub const DEFAULT_COLUMN_BUFFER_ENVS: &[&str] = &[DEFAULT_COLUMN_BUFFER_ENV];
40#[cfg(any(feature = "view", feature = "help"))]
42pub const DEFAULT_MINIMUM_AUTOMATIC_WIDTH: u16 = ctl_term::DEFAULT_MINIMUM_WIDTH;
43pub const DEFAULT_FALLBACK_WIDTH: u16 = ctl_term::DEFAULT_FALLBACK_WIDTH;
45pub const DEFAULT_RECORD_STYLE_ENV: &str = "CTL_CORE_RECORD_STYLE";
49pub const DEFAULT_RECORD_STYLE_ENVS: &[&str] = &[DEFAULT_RECORD_STYLE_ENV];
52pub const DEFAULT_LIST_STYLE_ENV: &str = "CTL_CORE_LIST_STYLE";
56pub const DEFAULT_LIST_STYLE_ENVS: &[&str] = &[DEFAULT_LIST_STYLE_ENV];
59pub const DEFAULT_ROW_SEPARATION_ENV: &str = "CTL_CORE_ROW_SEPARATION";
63pub const DEFAULT_ROW_SEPARATION_ENVS: &[&str] = &[DEFAULT_ROW_SEPARATION_ENV];
66
67const NATURAL_RULE_WIDTH: u16 = 40;
70
71pub(crate) trait EnvChoice: Copy + 'static {
73 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
101pub(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#[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
134pub(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#[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 #[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 #[must_use]
206 pub const fn color(mut self, color: ColorMode) -> Self {
207 self.color = color;
208 self
209 }
210
211 #[must_use]
215 pub const fn theme(mut self, theme: Theme) -> Self {
216 self.theme = theme;
217 self
218 }
219
220 #[must_use]
222 pub const fn record_style(mut self, style: RecordStyle) -> Self {
223 self.record_style = Some(style);
224 self
225 }
226
227 #[must_use]
230 pub const fn frame(mut self, frame: Frame) -> Self {
231 self.frame = Some(frame);
232 self
233 }
234
235 #[must_use]
237 pub const fn row_separation(mut self, separation: RowSeparation) -> Self {
238 self.row_separation = Some(separation);
239 self
240 }
241
242 #[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 #[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 #[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 #[must_use]
269 pub const fn width(mut self, width: u16) -> Self {
270 self.width = Some(width);
271 self
272 }
273
274 #[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 #[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 #[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 #[must_use]
305 pub const fn fallback_width(mut self, width: Option<u16>) -> Self {
306 self.fallback_width = width;
307 self
308 }
309
310 #[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 #[must_use]
328 pub fn resolved_record_style(self) -> RecordStyle {
329 self.record_style_with(process_env)
330 }
331
332 #[must_use]
335 pub fn resolved_frame(self) -> Frame {
336 self.frame_with(process_env)
337 }
338
339 #[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 #[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 #[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 #[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 #[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 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
439pub(crate) struct Renderer {
440 options: RenderOptions,
441}
442
443impl Renderer {
444 pub(crate) const fn new(options: RenderOptions) -> Self {
446 Self { options }
447 }
448
449 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 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 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 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
600fn 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 #[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#[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#[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}