1use 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
9const LABEL_WIDTH: u16 = 14;
11
12pub fn height(modal: &ChartExportModal) -> u16 {
15 modal.shown().len() as u16 + STATUS_ROWS + 4
16}
17
18const 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 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 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 #[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 #[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}