Skip to main content

osp_cli/ui/
mod.rs

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    /// Shared plain-mode baseline for tests so new fields only need one update.
133    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            // Plain mode is a strict no-color/no-unicode fallback.
214            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, &copy_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, &copy_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}