datui-lib 0.4.0

Data Exploration in the Terminal (library)
Documentation
//! Chart export dialog rendering: one Surface, a FormRow per field, the actions in
//! the footer.

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;

/// The value column's offset: past the longest label, "Description:", plus air.
const LABEL_WIDTH: u16 = 14;

/// Rows the dialog wants: the fields the chart's type shows, a blank and the
/// status line, the blank row and the footer, the border.
pub fn height(modal: &ChartExportModal) -> u16 {
    modal.shown().len() as u16 + STATUS_ROWS + 4
}

/// The blank under the fields and the status line, which says why Enter did not
/// write. Kept whether or not there is a reason, so nothing moves when one comes.
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),
            _ => {}
        }
    }
    // The status line keeps its place at the bottom while there is room for a
    // field above it.
    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,
        );
    }
    // Overlays scroll inside a capped frame: keep the focused field on screen.
    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()
    }

    /// One border, a FormRow per field, the actions as footer chips: no bordered
    /// fields, no buttons.
    #[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:?}"
        );
    }

    /// Why Enter did not write sits on the last line above the footer, under a
    /// blank, with every field still drawn above it.
    #[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);
        }
    }
}