1pub mod chrome;
2pub mod clipboard;
3mod display;
4pub mod document;
5pub mod format;
6pub mod inline;
7pub mod interactive;
8mod layout;
9pub mod messages;
10pub(crate) mod presentation;
11mod renderer;
12pub mod style;
13pub mod theme;
14pub(crate) mod theme_loader;
15mod width;
16
17use crate::core::output::{ColorMode, OutputFormat, RenderMode, UnicodeMode};
18use crate::core::output_model::{OutputItems, OutputResult};
19use crate::core::row::Row;
20use crate::ui::chrome::SectionFrameStyle;
21
22pub use document::{
23 CodeBlock, Document, JsonBlock, LineBlock, LinePart, MregBlock, MregEntry, MregRow, MregValue,
24 PanelBlock, PanelRules, TableAlign, TableBlock, TableStyle, ValueBlock,
25};
26pub use inline::{line_from_inline, parts_from_inline, render_inline};
27pub use interactive::{Interactive, InteractiveResult, InteractiveRuntime, Spinner};
28pub use style::StyleOverrides;
29use theme::ThemeDefinition;
30
31#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct RenderRuntime {
33 pub stdout_is_tty: bool,
34 pub terminal: Option<String>,
35 pub no_color: bool,
36 pub width: Option<usize>,
37 pub locale_utf8: Option<bool>,
38}
39
40#[derive(Debug, Clone)]
41pub struct RenderSettings {
42 pub format: OutputFormat,
43 pub mode: RenderMode,
44 pub color: ColorMode,
45 pub unicode: UnicodeMode,
46 pub width: Option<usize>,
47 pub margin: usize,
48 pub indent_size: usize,
49 pub short_list_max: usize,
50 pub medium_list_max: usize,
51 pub grid_padding: usize,
52 pub grid_columns: Option<usize>,
53 pub column_weight: usize,
54 pub table_overflow: TableOverflow,
55 pub table_border: TableBorderStyle,
56 pub mreg_stack_min_col_width: usize,
57 pub mreg_stack_overflow_ratio: usize,
58 pub theme_name: String,
59 pub theme: Option<ThemeDefinition>,
60 pub style_overrides: StyleOverrides,
61 pub chrome_frame: SectionFrameStyle,
62 pub runtime: RenderRuntime,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum RenderBackend {
67 Plain,
68 Rich,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum TableOverflow {
73 None,
74 Clip,
75 Ellipsis,
76 Wrap,
77}
78
79impl TableOverflow {
80 pub fn parse(value: &str) -> Option<Self> {
81 match value.trim().to_ascii_lowercase().as_str() {
82 "none" | "visible" => Some(Self::None),
83 "clip" | "hidden" | "crop" => Some(Self::Clip),
84 "ellipsis" | "truncate" => Some(Self::Ellipsis),
85 "wrap" | "wrapped" => Some(Self::Wrap),
86 _ => None,
87 }
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
92pub enum TableBorderStyle {
93 None,
94 #[default]
95 Square,
96 Round,
97}
98
99impl TableBorderStyle {
100 pub fn parse(value: &str) -> Option<Self> {
101 match value.trim().to_ascii_lowercase().as_str() {
102 "none" | "plain" => Some(Self::None),
103 "square" | "box" | "boxed" => Some(Self::Square),
104 "round" | "rounded" => Some(Self::Round),
105 _ => None,
106 }
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct ResolvedRenderSettings {
112 pub backend: RenderBackend,
113 pub color: bool,
114 pub unicode: bool,
115 pub width: Option<usize>,
116 pub margin: usize,
117 pub indent_size: usize,
118 pub short_list_max: usize,
119 pub medium_list_max: usize,
120 pub grid_padding: usize,
121 pub grid_columns: Option<usize>,
122 pub column_weight: usize,
123 pub table_overflow: TableOverflow,
124 pub table_border: TableBorderStyle,
125 pub theme_name: String,
126 pub theme: ThemeDefinition,
127 pub style_overrides: StyleOverrides,
128 pub chrome_frame: SectionFrameStyle,
129}
130
131impl RenderSettings {
132 pub fn test_plain(format: OutputFormat) -> Self {
134 Self {
135 format,
136 mode: RenderMode::Plain,
137 color: ColorMode::Never,
138 unicode: UnicodeMode::Never,
139 width: None,
140 margin: 0,
141 indent_size: 2,
142 short_list_max: 1,
143 medium_list_max: 5,
144 grid_padding: 4,
145 grid_columns: None,
146 column_weight: 3,
147 table_overflow: TableOverflow::Clip,
148 table_border: TableBorderStyle::Square,
149 mreg_stack_min_col_width: 10,
150 mreg_stack_overflow_ratio: 200,
151 theme_name: crate::ui::theme::DEFAULT_THEME_NAME.to_string(),
152 theme: None,
153 style_overrides: crate::ui::style::StyleOverrides::default(),
154 chrome_frame: SectionFrameStyle::Top,
155 runtime: RenderRuntime::default(),
156 }
157 }
158
159 fn resolve_color_mode(&self) -> bool {
160 match self.color {
161 ColorMode::Always => true,
162 ColorMode::Never => false,
163 ColorMode::Auto => !self.runtime.no_color && self.runtime.stdout_is_tty,
164 }
165 }
166
167 fn resolve_unicode_mode(&self) -> bool {
168 match self.unicode {
169 UnicodeMode::Always => true,
170 UnicodeMode::Never => false,
171 UnicodeMode::Auto => {
172 if !self.runtime.stdout_is_tty {
173 return false;
174 }
175 if matches!(self.runtime.terminal.as_deref(), Some("dumb")) {
176 return false;
177 }
178 match self.runtime.locale_utf8 {
179 Some(true) => true,
180 Some(false) => false,
181 None => true,
182 }
183 }
184 }
185 }
186
187 pub fn resolve_render_settings(&self) -> ResolvedRenderSettings {
188 let backend = match self.mode {
189 RenderMode::Plain => RenderBackend::Plain,
190 RenderMode::Rich => RenderBackend::Rich,
191 RenderMode::Auto => {
192 if matches!(self.color, ColorMode::Always)
193 || matches!(self.unicode, UnicodeMode::Always)
194 {
195 RenderBackend::Rich
196 } else if !self.runtime.stdout_is_tty
197 || matches!(self.runtime.terminal.as_deref(), Some("dumb"))
198 {
199 RenderBackend::Plain
200 } else {
201 RenderBackend::Rich
202 }
203 }
204 };
205
206 let theme = self
207 .theme
208 .clone()
209 .unwrap_or_else(|| theme::resolve_theme(&self.theme_name));
210 let theme_name = theme::normalize_theme_name(&theme.id);
211
212 match backend {
213 RenderBackend::Plain => ResolvedRenderSettings {
215 backend,
216 color: false,
217 unicode: false,
218 width: self.resolve_width(),
219 margin: self.margin,
220 indent_size: self.indent_size.max(1),
221 short_list_max: self.short_list_max.max(1),
222 medium_list_max: self.medium_list_max.max(self.short_list_max.max(1) + 1),
223 grid_padding: self.grid_padding.max(1),
224 grid_columns: self.grid_columns.filter(|value| *value > 0),
225 column_weight: self.column_weight.max(1),
226 table_overflow: self.table_overflow,
227 table_border: self.table_border,
228 theme_name,
229 theme: theme.clone(),
230 style_overrides: self.style_overrides.clone(),
231 chrome_frame: self.chrome_frame,
232 },
233 RenderBackend::Rich => ResolvedRenderSettings {
234 backend,
235 color: self.resolve_color_mode(),
236 unicode: self.resolve_unicode_mode(),
237 width: self.resolve_width(),
238 margin: self.margin,
239 indent_size: self.indent_size.max(1),
240 short_list_max: self.short_list_max.max(1),
241 medium_list_max: self.medium_list_max.max(self.short_list_max.max(1) + 1),
242 grid_padding: self.grid_padding.max(1),
243 grid_columns: self.grid_columns.filter(|value| *value > 0),
244 column_weight: self.column_weight.max(1),
245 table_overflow: self.table_overflow,
246 table_border: self.table_border,
247 theme_name,
248 theme,
249 style_overrides: self.style_overrides.clone(),
250 chrome_frame: self.chrome_frame,
251 },
252 }
253 }
254
255 fn resolve_width(&self) -> Option<usize> {
256 if let Some(width) = self.width {
257 return (width > 0).then_some(width);
258 }
259 self.runtime.width.filter(|width| *width > 0)
260 }
261
262 fn plain_copy_settings(&self) -> Self {
263 Self {
264 format: self.format,
265 mode: RenderMode::Plain,
266 color: ColorMode::Never,
267 unicode: UnicodeMode::Never,
268 width: self.width,
269 margin: self.margin,
270 indent_size: self.indent_size,
271 short_list_max: self.short_list_max,
272 medium_list_max: self.medium_list_max,
273 grid_padding: self.grid_padding,
274 grid_columns: self.grid_columns,
275 column_weight: self.column_weight,
276 table_overflow: self.table_overflow,
277 table_border: self.table_border,
278 mreg_stack_min_col_width: self.mreg_stack_min_col_width,
279 mreg_stack_overflow_ratio: self.mreg_stack_overflow_ratio,
280 theme_name: self.theme_name.clone(),
281 theme: self.theme.clone(),
282 style_overrides: self.style_overrides.clone(),
283 chrome_frame: self.chrome_frame,
284 runtime: self.runtime.clone(),
285 }
286 }
287}
288
289pub fn render_rows(rows: &[Row], settings: &RenderSettings) -> String {
290 render_output(
291 &OutputResult {
292 items: OutputItems::Rows(rows.to_vec()),
293 meta: Default::default(),
294 },
295 settings,
296 )
297}
298
299pub fn render_output(output: &OutputResult, settings: &RenderSettings) -> String {
300 let resolved = settings.resolve_render_settings();
301 let document = format::build_document_from_output_resolved(output, settings, &resolved);
302 renderer::render_document(&document, resolved)
303}
304
305pub fn render_document(document: &Document, settings: &RenderSettings) -> String {
306 let resolved = settings.resolve_render_settings();
307 renderer::render_document(document, resolved)
308}
309
310pub fn render_rows_for_copy(rows: &[Row], settings: &RenderSettings) -> String {
311 render_output_for_copy(
312 &OutputResult {
313 items: OutputItems::Rows(rows.to_vec()),
314 meta: Default::default(),
315 },
316 settings,
317 )
318}
319
320pub fn render_output_for_copy(output: &OutputResult, settings: &RenderSettings) -> String {
321 let copy_settings = settings.plain_copy_settings();
322 let resolved = copy_settings.resolve_render_settings();
323 let document = format::build_document_from_output_resolved(output, ©_settings, &resolved);
324 renderer::render_document(&document, resolved)
325}
326
327pub fn render_document_for_copy(document: &Document, settings: &RenderSettings) -> String {
328 let copy_settings = settings.plain_copy_settings();
329 let resolved = copy_settings.resolve_render_settings();
330 renderer::render_document(document, resolved)
331}
332
333pub fn copy_rows_to_clipboard(
334 rows: &[Row],
335 settings: &RenderSettings,
336 clipboard: &clipboard::ClipboardService,
337) -> Result<(), clipboard::ClipboardError> {
338 copy_output_to_clipboard(
339 &OutputResult {
340 items: OutputItems::Rows(rows.to_vec()),
341 meta: Default::default(),
342 },
343 settings,
344 clipboard,
345 )
346}
347
348pub fn copy_output_to_clipboard(
349 output: &OutputResult,
350 settings: &RenderSettings,
351 clipboard: &clipboard::ClipboardService,
352) -> Result<(), clipboard::ClipboardError> {
353 let copy_settings = settings.plain_copy_settings();
354 let resolved = copy_settings.resolve_render_settings();
355 let document = format::build_document_from_output_resolved(output, ©_settings, &resolved);
356 let text = renderer::render_document(&document, resolved);
357 clipboard.copy_text(&text)
358}
359
360#[cfg(test)]
361mod tests {
362 use super::{
363 RenderBackend, RenderSettings, TableBorderStyle, TableOverflow, format, render_document,
364 render_document_for_copy, render_output, render_output_for_copy, render_rows_for_copy,
365 };
366 use crate::core::output::{ColorMode, OutputFormat, RenderMode, UnicodeMode};
367 use crate::core::output_model::OutputResult;
368 use crate::core::row::Row;
369 use crate::ui::document::{Block, MregValue, TableStyle};
370 use serde_json::json;
371
372 fn settings(format: OutputFormat) -> RenderSettings {
373 RenderSettings {
374 mode: RenderMode::Auto,
375 ..RenderSettings::test_plain(format)
376 }
377 }
378
379 #[test]
380 fn auto_selects_value_for_value_rows() {
381 let rows = vec![{
382 let mut row = Row::new();
383 row.insert("value".to_string(), json!("hello"));
384 row
385 }];
386
387 let document = format::build_document(&rows, &settings(OutputFormat::Auto));
388 assert!(matches!(document.blocks[0], Block::Value(_)));
389 }
390
391 #[test]
392 fn auto_selects_mreg_for_single_non_value_row() {
393 let rows = vec![{
394 let mut row = Row::new();
395 row.insert("uid".to_string(), json!("oistes"));
396 row
397 }];
398
399 let document = format::build_document(&rows, &settings(OutputFormat::Auto));
400 assert!(matches!(document.blocks[0], Block::Mreg(_)));
401 }
402
403 #[test]
404 fn auto_selects_table_for_multi_row_result() {
405 let rows = vec![
406 {
407 let mut row = Row::new();
408 row.insert("uid".to_string(), json!("one"));
409 row
410 },
411 {
412 let mut row = Row::new();
413 row.insert("uid".to_string(), json!("two"));
414 row
415 },
416 ];
417
418 let document = format::build_document(&rows, &settings(OutputFormat::Auto));
419 assert!(matches!(document.blocks[0], Block::Table(_)));
420 }
421
422 #[test]
423 fn mreg_block_models_scalar_and_vertical_list_values() {
424 let rows = vec![{
425 let mut row = Row::new();
426 row.insert("uid".to_string(), json!("oistes"));
427 row.insert("groups".to_string(), json!(["a", "b"]));
428 row
429 }];
430
431 let document = format::build_document(&rows, &settings(OutputFormat::Mreg));
432 let Block::Mreg(block) = &document.blocks[0] else {
433 panic!("expected mreg block");
434 };
435 assert_eq!(block.rows.len(), 1);
436 assert!(
437 block.rows[0]
438 .entries
439 .iter()
440 .any(|entry| matches!(entry.value, MregValue::Scalar(_)))
441 );
442 assert!(
443 block.rows[0]
444 .entries
445 .iter()
446 .any(|entry| matches!(entry.value, MregValue::VerticalList(_)))
447 );
448 }
449
450 #[test]
451 fn markdown_format_builds_markdown_table_block() {
452 let rows = vec![{
453 let mut row = Row::new();
454 row.insert("uid".to_string(), json!("oistes"));
455 row
456 }];
457
458 let document = format::build_document(&rows, &settings(OutputFormat::Markdown));
459 let Block::Table(table) = &document.blocks[0] else {
460 panic!("expected table block");
461 };
462 assert_eq!(table.style, TableStyle::Markdown);
463 }
464
465 #[test]
466 fn plain_mode_disables_color_and_unicode_even_when_forced() {
467 let settings = RenderSettings {
468 format: OutputFormat::Table,
469 color: ColorMode::Always,
470 unicode: UnicodeMode::Always,
471 ..RenderSettings::test_plain(OutputFormat::Table)
472 };
473
474 let resolved = settings.resolve_render_settings();
475 assert_eq!(resolved.backend, RenderBackend::Plain);
476 assert!(!resolved.color);
477 assert!(!resolved.unicode);
478 }
479
480 #[test]
481 fn rich_mode_keeps_forced_color_and_unicode() {
482 let settings = RenderSettings {
483 format: OutputFormat::Table,
484 mode: RenderMode::Rich,
485 color: ColorMode::Always,
486 unicode: UnicodeMode::Always,
487 ..RenderSettings::test_plain(OutputFormat::Table)
488 };
489
490 let resolved = settings.resolve_render_settings();
491 assert_eq!(resolved.backend, RenderBackend::Rich);
492 assert!(resolved.color);
493 assert!(resolved.unicode);
494 }
495
496 #[test]
497 fn copy_render_forces_plain_without_ansi_or_unicode_borders() {
498 let rows = vec![{
499 let mut row = Row::new();
500 row.insert("uid".to_string(), json!("oistes"));
501 row.insert(
502 "description".to_string(),
503 json!("very long text that will be shown"),
504 );
505 row
506 }];
507
508 let settings = RenderSettings {
509 format: OutputFormat::Table,
510 mode: RenderMode::Rich,
511 color: ColorMode::Always,
512 unicode: UnicodeMode::Always,
513 ..RenderSettings::test_plain(OutputFormat::Table)
514 };
515
516 let rendered = render_rows_for_copy(&rows, &settings);
517 assert!(!rendered.contains("\x1b["));
518 assert!(!rendered.contains('┌'));
519 assert!(rendered.contains('+'));
520 }
521
522 #[test]
523 fn table_border_style_parser_accepts_supported_names() {
524 assert_eq!(
525 TableBorderStyle::parse("none"),
526 Some(TableBorderStyle::None)
527 );
528 assert_eq!(
529 TableBorderStyle::parse("box"),
530 Some(TableBorderStyle::Square)
531 );
532 assert_eq!(
533 TableBorderStyle::parse("square"),
534 Some(TableBorderStyle::Square)
535 );
536 assert_eq!(
537 TableBorderStyle::parse("round"),
538 Some(TableBorderStyle::Round)
539 );
540 assert_eq!(
541 TableBorderStyle::parse("rounded"),
542 Some(TableBorderStyle::Round)
543 );
544 assert_eq!(TableBorderStyle::parse("mystery"), None);
545 }
546
547 #[test]
548 fn table_overflow_parser_accepts_aliases_unit() {
549 assert_eq!(TableOverflow::parse("visible"), Some(TableOverflow::None));
550 assert_eq!(TableOverflow::parse("crop"), Some(TableOverflow::Clip));
551 assert_eq!(
552 TableOverflow::parse("truncate"),
553 Some(TableOverflow::Ellipsis)
554 );
555 assert_eq!(TableOverflow::parse("wrapped"), Some(TableOverflow::Wrap));
556 assert_eq!(TableOverflow::parse("other"), None);
557 }
558
559 #[test]
560 fn auto_modes_respect_runtime_terminal_and_locale_unit() {
561 let settings = RenderSettings {
562 mode: RenderMode::Auto,
563 color: ColorMode::Auto,
564 unicode: UnicodeMode::Auto,
565 runtime: super::RenderRuntime {
566 stdout_is_tty: true,
567 terminal: Some("dumb".to_string()),
568 no_color: false,
569 width: Some(72),
570 locale_utf8: Some(false),
571 },
572 ..RenderSettings::test_plain(OutputFormat::Table)
573 };
574
575 let resolved = settings.resolve_render_settings();
576 assert_eq!(resolved.backend, RenderBackend::Plain);
577 assert!(!resolved.color);
578 assert!(!resolved.unicode);
579 assert_eq!(resolved.width, Some(72));
580 }
581
582 #[test]
583 fn auto_mode_forced_color_promotes_rich_backend_unit() {
584 let settings = RenderSettings {
585 mode: RenderMode::Auto,
586 color: ColorMode::Always,
587 unicode: UnicodeMode::Auto,
588 runtime: super::RenderRuntime {
589 stdout_is_tty: false,
590 terminal: Some("xterm-256color".to_string()),
591 no_color: false,
592 width: Some(80),
593 locale_utf8: Some(true),
594 },
595 ..RenderSettings::test_plain(OutputFormat::Table)
596 };
597
598 let resolved = settings.resolve_render_settings();
599 assert_eq!(resolved.backend, RenderBackend::Rich);
600 assert!(resolved.color);
601 }
602
603 #[test]
604 fn auto_mode_forced_unicode_promotes_rich_backend_unit() {
605 let settings = RenderSettings {
606 mode: RenderMode::Auto,
607 color: ColorMode::Auto,
608 unicode: UnicodeMode::Always,
609 runtime: super::RenderRuntime {
610 stdout_is_tty: false,
611 terminal: Some("dumb".to_string()),
612 no_color: true,
613 width: Some(64),
614 locale_utf8: Some(false),
615 },
616 ..RenderSettings::test_plain(OutputFormat::Table)
617 };
618
619 let resolved = settings.resolve_render_settings();
620 assert_eq!(resolved.backend, RenderBackend::Rich);
621 assert!(!resolved.color);
622 assert!(resolved.unicode);
623 }
624
625 #[test]
626 fn copy_helpers_force_plain_copy_settings_for_rows_unit() {
627 let rows = vec![{
628 let mut row = Row::new();
629 row.insert("value".to_string(), json!("hello"));
630 row
631 }];
632 let settings = RenderSettings {
633 mode: RenderMode::Rich,
634 color: ColorMode::Always,
635 unicode: UnicodeMode::Always,
636 ..RenderSettings::test_plain(OutputFormat::Value)
637 };
638
639 let copied = render_rows_for_copy(&rows, &settings);
640 assert_eq!(copied.trim(), "hello");
641 assert!(!copied.contains("\x1b["));
642 }
643
644 #[test]
645 fn render_document_helpers_force_plain_copy_mode_unit() {
646 let document = crate::ui::Document {
647 blocks: vec![Block::Line(crate::ui::LineBlock {
648 parts: vec![crate::ui::LinePart {
649 text: "hello".to_string(),
650 token: None,
651 }],
652 })],
653 };
654 let settings = RenderSettings {
655 mode: RenderMode::Rich,
656 color: ColorMode::Always,
657 unicode: UnicodeMode::Always,
658 ..RenderSettings::test_plain(OutputFormat::Table)
659 };
660
661 let rich = render_document(&document, &settings);
662 let copied = render_document_for_copy(&document, &settings);
663
664 assert!(rich.contains("hello"));
665 assert!(copied.contains("hello"));
666 assert!(!copied.contains("\x1b["));
667 }
668
669 #[test]
670 fn json_output_snapshot_and_copy_contracts_are_stable_unit() {
671 let rows = vec![{
672 let mut row = Row::new();
673 row.insert("uid".to_string(), json!("alice"));
674 row.insert("count".to_string(), json!(2));
675 row
676 }];
677 let settings = RenderSettings {
678 format: OutputFormat::Json,
679 mode: RenderMode::Rich,
680 color: ColorMode::Always,
681 unicode: UnicodeMode::Always,
682 ..RenderSettings::test_plain(OutputFormat::Json)
683 };
684
685 let output = OutputResult::from_rows(rows);
686 let rendered = render_output(&output, &settings);
687 let copied = render_output_for_copy(&output, &settings);
688
689 assert!(rendered.contains("\"uid\""));
690 assert!(rendered.contains("\x1b["));
691 assert_eq!(
692 copied,
693 "[\n {\n \"uid\": \"alice\",\n \"count\": 2\n }\n]\n"
694 );
695 assert!(!copied.contains("\x1b["));
696 }
697}