Skip to main content

datui_lib/widgets/
chart_export_modal.rs

1//! Chart export dialog rendering: a FormView, a row per field, the actions in the
2//! footer.
3
4use crate::chart::chart_export_modal::{ChartExportModal, FIELDS};
5use crate::render::context::RenderContext;
6use crate::widgets::ui::{FormLine, FormValue, FormView, HintBar};
7use ratatui::layout::Rect;
8
9/// The value column's offset: past the longest label, "Description:", plus air.
10const LABEL_WIDTH: u16 = 14;
11
12/// Rows the dialog wants: the fields the chart's type shows, a blank and the
13/// status line, the blank row and the footer, the border.
14pub fn height(modal: &ChartExportModal) -> u16 {
15    modal.shown().len() as u16 + STATUS_ROWS + 4
16}
17
18/// The blank under the fields and the status line, which says why Enter did not
19/// write. Kept whether or not there is a reason, so nothing moves when one comes.
20const STATUS_ROWS: u16 = 2;
21
22pub fn render_chart_export_modal(
23    area: Rect,
24    buf: &mut ratatui::buffer::Buffer,
25    modal: &mut ChartExportModal,
26    ctx: &RenderContext,
27) {
28    let footer = HintBar::from_ctx(ctx)
29        .screen(datui_cli::keys::Context::Chart)
30        .group("Export dialog")
31        .key("Enter")
32        .weight(3)
33        .key("Tab")
34        .weight(1)
35        .key("Esc")
36        .weight(4);
37    let focus = modal.focus;
38    for (field, _) in FIELDS {
39        if let Some(input) = modal.input_mut(field) {
40            input.set_focused(field == focus);
41        }
42    }
43    let rows = modal
44        .shown()
45        .into_iter()
46        .filter_map(|field| {
47            let value = match modal.choice(field) {
48                Some(choice) => FormValue::Choice(choice),
49                None => FormValue::Input(modal.input(field)?),
50            };
51            Some(FormLine::Field(field, field.label(), value))
52        })
53        .collect();
54    let error = modal.error.clone().unwrap_or_default();
55    FormView {
56        title: "Export Chart",
57        screen: datui_cli::keys::Context::Chart,
58        footer: Some(footer),
59        label_width: LABEL_WIDTH,
60        rows,
61        focused: Some(focus),
62        picker: None,
63        status: Some((error, ratatui::style::Style::default().fg(ctx.warning))),
64        shields: true,
65    }
66    .render::<ChartExportModal>(area, buf, ctx);
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use crate::chart::chart_export_modal::ExportDefaults;
73    use ratatui::buffer::Buffer;
74
75    fn render_rows(width: u16, height: u16) -> Vec<String> {
76        let ctx = RenderContext::for_test();
77        let config = crate::config::AppConfig::default();
78        let theme = crate::config::Theme::from_config(&config.theme).unwrap();
79        let mut modal = ChartExportModal::new();
80        modal.open(&theme, 1000, ExportDefaults::default());
81        modal.path_input.set_value("out.png");
82        let area = Rect::new(0, 0, width, height);
83        let mut buf = Buffer::empty(area);
84        render_chart_export_modal(area, &mut buf, &mut modal, &ctx);
85        (0..height)
86            .map(|y| {
87                (0..width)
88                    .map(|x| buf[(x, y)].symbol().to_string())
89                    .collect::<String>()
90            })
91            .collect()
92    }
93
94    /// One border, a FormRow per field, the actions as footer chips: no bordered
95    /// fields, no buttons.
96    #[test]
97    fn one_surface_with_a_row_per_field() {
98        let rows = render_rows(64, height(&ChartExportModal::new()));
99        assert!(rows[0].contains("Export Chart"), "title: {:?}", rows[0]);
100        for row in &rows[1..rows.len() - 1] {
101            assert!(
102                !row.contains('╭') && !row.contains('╰'),
103                "a second border inside the surface: {row:?}"
104            );
105        }
106        let text = rows.join("\n");
107        for label in [
108            "Path:",
109            "Format:",
110            "PNG",
111            "Style:",
112            "Light",
113            "Size:",
114            "Document",
115            "Width:",
116            "1600",
117            "Height:",
118            "1000",
119            "Legend:",
120            "Title:",
121            "Description:",
122            "Notes:",
123            "Source:",
124            "Byline:",
125        ] {
126            assert!(text.contains(label), "{label}:\n{text}");
127        }
128        let footer = &rows[rows.len() - 2];
129        assert!(
130            footer.contains("Enter") && footer.contains("Export") && footer.contains("Esc"),
131            "footer chips: {footer:?}"
132        );
133    }
134
135    /// Why Enter did not write sits on the last line above the footer, under a
136    /// blank, with every field still drawn above it.
137    #[test]
138    fn the_reason_sits_on_the_status_line() {
139        let ctx = RenderContext::for_test();
140        let mut modal = ChartExportModal::new();
141        modal.error = Some("Enter a file path.".to_string());
142        let area = Rect::new(0, 0, 64, height(&ChartExportModal::new()));
143        let mut buf = Buffer::empty(area);
144        render_chart_export_modal(area, &mut buf, &mut modal, &ctx);
145        let rows: Vec<String> = (0..height(&ChartExportModal::new()))
146            .map(|y| (0..64).map(|x| buf[(x, y)].symbol().to_string()).collect())
147            .collect();
148        let at = rows
149            .iter()
150            .position(|row| row.contains("Enter a file path."));
151        assert_eq!(
152            at,
153            Some(usize::from(height(&ChartExportModal::new())) - 4),
154            "{rows:#?}"
155        );
156        assert!(
157            rows[usize::from(height(&ChartExportModal::new())) - 5]
158                .trim_matches(['│', ' '])
159                .is_empty()
160        );
161        assert!(rows.iter().any(|row| row.contains("Byline:")));
162    }
163
164    #[test]
165    fn a_tiny_area_never_panics() {
166        for (w, h) in [(0, 0), (3, 2), (10, 4), (30, 6), (64, 10)] {
167            let _ = render_rows(w, h);
168        }
169    }
170}