use crate::chart_export_modal::{ChartExportFocus, ChartExportModal, FIELDS};
use crate::render::context::RenderContext;
use crate::widgets::ui::{FormRow, FormValue, HintBar, Surface};
use ratatui::layout::Rect;
const LABEL_WIDTH: u16 = 14;
pub fn height(modal: &ChartExportModal) -> u16 {
modal.shown().len() as u16 + STATUS_ROWS + 4
}
const STATUS_ROWS: u16 = 2;
pub fn render_chart_export_modal(
area: Rect,
buf: &mut ratatui::buffer::Buffer,
modal: &mut ChartExportModal,
ctx: &RenderContext,
) {
let footer = HintBar::from_ctx(ctx)
.hint_weighted("Enter", "Export", 3)
.hint_weighted("Tab", "Next", 1)
.hint_weighted("Esc", "Cancel", 4);
crate::pointer::record(area, crate::pointer::Hit::Modal);
let content = Surface::new("Export Chart")
.footer(&footer)
.render(area, buf, ctx);
if content.height < 1 || content.width < 4 {
return;
}
let focus = modal.focus;
for field in FIELDS {
let focused = field == focus;
match field {
ChartExportFocus::PathInput => modal.path_input.set_focused(focused),
ChartExportFocus::WidthInput => modal.width_input.set_focused(focused),
ChartExportFocus::HeightInput => modal.height_input.set_focused(focused),
ChartExportFocus::TitleInput => modal.title_input.set_focused(focused),
ChartExportFocus::DescriptionInput => modal.description_input.set_focused(focused),
ChartExportFocus::NotesInput => modal.notes_input.set_focused(focused),
ChartExportFocus::SourceInput => modal.source_input.set_focused(focused),
ChartExportFocus::BylineInput => modal.byline_input.set_focused(focused),
_ => {}
}
}
let status_rows = if content.height > STATUS_ROWS {
STATUS_ROWS
} else {
0
};
if status_rows > 0
&& let Some(error) = modal.error.as_deref()
{
let width = content.width.saturating_sub(1);
ratatui::widgets::Widget::render(
ratatui::widgets::Paragraph::new(crate::widgets::data_quality::fit(
error,
width as usize,
))
.style(ratatui::style::Style::default().fg(ctx.warning)),
Rect {
x: content.x + 1,
y: content.bottom() - 1,
width,
height: 1,
},
buf,
);
}
let rows = (content.height - status_rows) as usize;
let shown = modal.shown();
let at = shown.iter().position(|f| *f == focus).unwrap_or(0);
let first = at.saturating_sub(rows.saturating_sub(1));
for (i, field) in shown.iter().enumerate().skip(first).take(rows) {
let value = match modal.choice(*field) {
Some(choice) => FormValue::Choice(choice),
None => match modal.input(*field) {
Some(input) => FormValue::Input(input),
None => continue,
},
};
let row = Rect {
y: content.y + (i - first) as u16,
height: 1,
..content
};
FormRow {
label: field.label(),
value,
focused: *field == focus,
label_width: LABEL_WIDTH,
}
.render(row, buf, ctx);
crate::pointer::record_field::<ChartExportModal>(row, *field);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chart_export_modal::ExportDefaults;
use ratatui::buffer::Buffer;
fn render_rows(width: u16, height: u16) -> Vec<String> {
let ctx = RenderContext::for_test();
let config = crate::config::AppConfig::default();
let theme = crate::config::Theme::from_config(&config.theme).unwrap();
let mut modal = ChartExportModal::new();
modal.open(&theme, 1000, ExportDefaults::default());
modal.path_input.set_value("out.png");
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
render_chart_export_modal(area, &mut buf, &mut modal, &ctx);
(0..height)
.map(|y| {
(0..width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect()
}
#[test]
fn one_surface_with_a_row_per_field() {
let rows = render_rows(64, height(&ChartExportModal::new()));
assert!(rows[0].contains("Export Chart"), "title: {:?}", rows[0]);
for row in &rows[1..rows.len() - 1] {
assert!(
!row.contains('╭') && !row.contains('╰'),
"a second border inside the surface: {row:?}"
);
}
let text = rows.join("\n");
for label in [
"Path:",
"Format:",
"PNG",
"Style:",
"Light",
"Size:",
"Document",
"Width:",
"1600",
"Height:",
"1000",
"Legend:",
"Title:",
"Description:",
"Notes:",
"Source:",
"Byline:",
] {
assert!(text.contains(label), "{label}:\n{text}");
}
let footer = &rows[rows.len() - 2];
assert!(
footer.contains("Enter") && footer.contains("Export") && footer.contains("Esc"),
"footer chips: {footer:?}"
);
}
#[test]
fn the_reason_sits_on_the_status_line() {
let ctx = RenderContext::for_test();
let mut modal = ChartExportModal::new();
modal.active = true;
modal.error = Some("Enter a file path.".to_string());
let area = Rect::new(0, 0, 64, height(&ChartExportModal::new()));
let mut buf = Buffer::empty(area);
render_chart_export_modal(area, &mut buf, &mut modal, &ctx);
let rows: Vec<String> = (0..height(&ChartExportModal::new()))
.map(|y| (0..64).map(|x| buf[(x, y)].symbol().to_string()).collect())
.collect();
let at = rows
.iter()
.position(|row| row.contains("Enter a file path."));
assert_eq!(
at,
Some(usize::from(height(&ChartExportModal::new())) - 4),
"{rows:#?}"
);
assert!(
rows[usize::from(height(&ChartExportModal::new())) - 5]
.trim_matches(['│', ' '])
.is_empty()
);
assert!(rows.iter().any(|row| row.contains("Byline:")));
}
#[test]
fn a_tiny_area_never_panics() {
for (w, h) in [(0, 0), (3, 2), (10, 4), (30, 6), (64, 10)] {
let _ = render_rows(w, h);
}
}
}