Skip to main content

datui_lib/widgets/
chart_export_modal.rs

1//! Chart export dialog rendering: one Surface, a FormRow per field, the actions in
2//! the footer.
3
4use crate::chart_export_modal::{ChartExportFocus, ChartExportModal, FIELDS};
5use crate::render::context::RenderContext;
6use crate::widgets::ui::{FormRow, FormValue, HintBar, Surface};
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        .hint_weighted("Enter", "Export", 3)
30        .hint_weighted("Tab", "Next", 1)
31        .hint_weighted("Esc", "Cancel", 4);
32    crate::pointer::record(area, crate::pointer::Hit::Modal);
33    let content = Surface::new("Export Chart")
34        .footer(&footer)
35        .render(area, buf, ctx);
36    if content.height < 1 || content.width < 4 {
37        return;
38    }
39    let focus = modal.focus;
40    for field in FIELDS {
41        let focused = field == focus;
42        match field {
43            ChartExportFocus::PathInput => modal.path_input.set_focused(focused),
44            ChartExportFocus::WidthInput => modal.width_input.set_focused(focused),
45            ChartExportFocus::HeightInput => modal.height_input.set_focused(focused),
46            ChartExportFocus::TitleInput => modal.title_input.set_focused(focused),
47            ChartExportFocus::DescriptionInput => modal.description_input.set_focused(focused),
48            ChartExportFocus::NotesInput => modal.notes_input.set_focused(focused),
49            ChartExportFocus::SourceInput => modal.source_input.set_focused(focused),
50            ChartExportFocus::BylineInput => modal.byline_input.set_focused(focused),
51            _ => {}
52        }
53    }
54    // The status line keeps its place at the bottom while there is room for a
55    // field above it.
56    let status_rows = if content.height > STATUS_ROWS {
57        STATUS_ROWS
58    } else {
59        0
60    };
61    if status_rows > 0
62        && let Some(error) = modal.error.as_deref()
63    {
64        let width = content.width.saturating_sub(1);
65        ratatui::widgets::Widget::render(
66            ratatui::widgets::Paragraph::new(crate::widgets::data_quality::fit(
67                error,
68                width as usize,
69            ))
70            .style(ratatui::style::Style::default().fg(ctx.warning)),
71            Rect {
72                x: content.x + 1,
73                y: content.bottom() - 1,
74                width,
75                height: 1,
76            },
77            buf,
78        );
79    }
80    // Overlays scroll inside a capped frame: keep the focused field on screen.
81    let rows = (content.height - status_rows) as usize;
82    let shown = modal.shown();
83    let at = shown.iter().position(|f| *f == focus).unwrap_or(0);
84    let first = at.saturating_sub(rows.saturating_sub(1));
85    for (i, field) in shown.iter().enumerate().skip(first).take(rows) {
86        let value = match modal.choice(*field) {
87            Some(choice) => FormValue::Choice(choice),
88            None => match modal.input(*field) {
89                Some(input) => FormValue::Input(input),
90                None => continue,
91            },
92        };
93        let row = Rect {
94            y: content.y + (i - first) as u16,
95            height: 1,
96            ..content
97        };
98        FormRow {
99            label: field.label(),
100            value,
101            focused: *field == focus,
102            label_width: LABEL_WIDTH,
103        }
104        .render(row, buf, ctx);
105        crate::pointer::record_field::<ChartExportModal>(row, *field);
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::chart_export_modal::ExportDefaults;
113    use ratatui::buffer::Buffer;
114
115    fn render_rows(width: u16, height: u16) -> Vec<String> {
116        let ctx = RenderContext::for_test();
117        let config = crate::config::AppConfig::default();
118        let theme = crate::config::Theme::from_config(&config.theme).unwrap();
119        let mut modal = ChartExportModal::new();
120        modal.open(&theme, 1000, ExportDefaults::default());
121        modal.path_input.set_value("out.png");
122        let area = Rect::new(0, 0, width, height);
123        let mut buf = Buffer::empty(area);
124        render_chart_export_modal(area, &mut buf, &mut modal, &ctx);
125        (0..height)
126            .map(|y| {
127                (0..width)
128                    .map(|x| buf[(x, y)].symbol().to_string())
129                    .collect::<String>()
130            })
131            .collect()
132    }
133
134    /// One border, a FormRow per field, the actions as footer chips: no bordered
135    /// fields, no buttons.
136    #[test]
137    fn one_surface_with_a_row_per_field() {
138        let rows = render_rows(64, height(&ChartExportModal::new()));
139        assert!(rows[0].contains("Export Chart"), "title: {:?}", rows[0]);
140        for row in &rows[1..rows.len() - 1] {
141            assert!(
142                !row.contains('╭') && !row.contains('╰'),
143                "a second border inside the surface: {row:?}"
144            );
145        }
146        let text = rows.join("\n");
147        for label in [
148            "Path:",
149            "Format:",
150            "PNG",
151            "Style:",
152            "Light",
153            "Size:",
154            "Document",
155            "Width:",
156            "1600",
157            "Height:",
158            "1000",
159            "Legend:",
160            "Title:",
161            "Description:",
162            "Notes:",
163            "Source:",
164            "Byline:",
165        ] {
166            assert!(text.contains(label), "{label}:\n{text}");
167        }
168        let footer = &rows[rows.len() - 2];
169        assert!(
170            footer.contains("Enter") && footer.contains("Export") && footer.contains("Esc"),
171            "footer chips: {footer:?}"
172        );
173    }
174
175    /// Why Enter did not write sits on the last line above the footer, under a
176    /// blank, with every field still drawn above it.
177    #[test]
178    fn the_reason_sits_on_the_status_line() {
179        let ctx = RenderContext::for_test();
180        let mut modal = ChartExportModal::new();
181        modal.active = true;
182        modal.error = Some("Enter a file path.".to_string());
183        let area = Rect::new(0, 0, 64, height(&ChartExportModal::new()));
184        let mut buf = Buffer::empty(area);
185        render_chart_export_modal(area, &mut buf, &mut modal, &ctx);
186        let rows: Vec<String> = (0..height(&ChartExportModal::new()))
187            .map(|y| (0..64).map(|x| buf[(x, y)].symbol().to_string()).collect())
188            .collect();
189        let at = rows
190            .iter()
191            .position(|row| row.contains("Enter a file path."));
192        assert_eq!(
193            at,
194            Some(usize::from(height(&ChartExportModal::new())) - 4),
195            "{rows:#?}"
196        );
197        assert!(
198            rows[usize::from(height(&ChartExportModal::new())) - 5]
199                .trim_matches(['│', ' '])
200                .is_empty()
201        );
202        assert!(rows.iter().any(|row| row.contains("Byline:")));
203    }
204
205    #[test]
206    fn a_tiny_area_never_panics() {
207        for (w, h) in [(0, 0), (3, 2), (10, 4), (30, 6), (64, 10)] {
208            let _ = render_rows(w, h);
209        }
210    }
211}