clap-tui 0.1.1

Auto-generate a TUI from clap commands
Documentation
use ratatui::layout::Rect;

use crate::form_editor;
use crate::input::UiState;
use crate::spec::ArgSpec;

pub(crate) const REPEATED_CONTROL_WIDTH: u16 = 8;
pub(crate) const REPEATED_CONTROL_ROW_WIDTH: u16 = 8;
pub(crate) const REPEATED_ROW_HEIGHT: u16 = 3;
const FIELD_GAP_HEIGHT: u16 = 1;

#[derive(Debug, Clone)]
pub(crate) struct RepeatedFieldProjection {
    pub(crate) input: Rect,
    pub(crate) rows: Vec<Rect>,
    pub(crate) description: Option<Rect>,
    #[allow(dead_code)]
    pub(crate) total_height: u16,
}

impl RepeatedFieldProjection {
    pub(crate) fn row(&self, index: usize) -> Option<Rect> {
        self.rows.get(index).copied()
    }
}

pub(crate) fn repeated_input_height(ui: &UiState, arg: &ArgSpec, value: &str) -> u16 {
    let editor = form_editor::editor_for_render(ui, arg.owner_path(), arg, value);
    let rows = editor.row_count().max(1);
    if rows <= 1 {
        REPEATED_ROW_HEIGHT
    } else {
        u16::try_from(rows)
            .unwrap_or(u16::MAX)
            .saturating_mul(REPEATED_ROW_HEIGHT)
    }
}

#[allow(clippy::too_many_arguments)]
pub(crate) fn project_repeated_field(
    ui: &UiState,
    arg: &ArgSpec,
    value: &str,
    field_top: u16,
    input_x: u16,
    input_width: u16,
    show_description: bool,
    label_height: u16,
) -> RepeatedFieldProjection {
    project_repeated_field_with_input_height(
        repeated_input_height(ui, arg, value),
        field_top,
        input_x,
        input_width,
        show_description,
        label_height,
    )
}

pub(crate) fn project_repeated_field_with_input_height(
    input_height: u16,
    field_top: u16,
    input_x: u16,
    input_width: u16,
    show_description: bool,
    label_height: u16,
) -> RepeatedFieldProjection {
    let input_height = input_height.max(REPEATED_ROW_HEIGHT);
    let input = Rect::new(input_x, field_top, input_width, input_height);
    let row_count =
        usize::from(input_height.saturating_add(REPEATED_ROW_HEIGHT - 1) / REPEATED_ROW_HEIGHT)
            .max(1);
    let rows = (0..row_count)
        .filter_map(|index| {
            let y = input.y.saturating_add(
                u16::try_from(index)
                    .ok()?
                    .saturating_mul(REPEATED_ROW_HEIGHT),
            );
            Some(Rect::new(input.x, y, input.width, REPEATED_ROW_HEIGHT))
        })
        .collect();
    let content_height = input_height.max(label_height);
    let description = show_description.then(|| {
        Rect::new(
            input_x,
            field_top.saturating_add(content_height),
            input_width,
            1,
        )
    });
    let total_height = content_height
        .saturating_add(u16::from(show_description))
        .saturating_add(FIELD_GAP_HEIGHT);

    RepeatedFieldProjection {
        input,
        rows,
        description,
        total_height,
    }
}

pub(crate) fn repeated_row_textarea_rect(
    row_rect: Rect,
    show_remove: bool,
    show_add: bool,
) -> Rect {
    let with_controls = show_remove || show_add;
    let width = if with_controls && row_rect.width > REPEATED_CONTROL_WIDTH {
        row_rect
            .width
            .saturating_sub(REPEATED_CONTROL_WIDTH)
            .saturating_sub(1)
    } else {
        row_rect.width
    };
    Rect::new(row_rect.x, row_rect.y, width, row_rect.height)
}

pub(crate) fn repeated_remove_rect(
    row_rect: Rect,
    show_remove: bool,
    show_add: bool,
) -> Option<Rect> {
    if !show_remove || row_rect.height < 2 {
        return None;
    }
    let strip_start = row_rect
        .x
        .saturating_add(row_rect.width.saturating_sub(REPEATED_CONTROL_WIDTH));
    let x = if show_add {
        strip_start
    } else {
        strip_start.saturating_add(2)
    };
    Some(Rect::new(
        x,
        row_rect
            .y
            .saturating_add(row_rect.height.saturating_sub(1).min(1)),
        3,
        1,
    ))
}

pub(crate) fn repeated_add_rect(row_rect: Rect) -> Option<Rect> {
    if row_rect.height < 2 {
        return None;
    }
    Some(Rect::new(
        row_rect.x.saturating_add(
            row_rect
                .width
                .saturating_sub(REPEATED_CONTROL_ROW_WIDTH)
                .saturating_add(4),
        ),
        row_rect
            .y
            .saturating_add(row_rect.height.saturating_sub(1).min(1)),
        3,
        1,
    ))
}

#[cfg(test)]
mod tests {
    use clap::{Arg, ArgAction, Command};

    use super::*;
    use crate::input::AppState;

    #[test]
    fn repeated_projection_places_rows_and_description_from_shared_geometry() {
        let mut state = AppState::from_command(
            &Command::new("tool").arg(
                Arg::new("include")
                    .long("include")
                    .action(ArgAction::Append)
                    .num_args(1)
                    .help("Include path"),
            ),
        );
        state.domain.set_text_value("include", "alpha\nbeta\ngamma");
        let arg = state.domain.arg_for_input("include").expect("include arg");

        let projection =
            project_repeated_field(&state.ui, arg, "alpha\nbeta\ngamma", 7, 12, 24, true, 1);

        assert_eq!(projection.input, Rect::new(12, 7, 24, 9));
        assert_eq!(projection.row(0), Some(Rect::new(12, 7, 24, 3)));
        assert_eq!(projection.row(1), Some(Rect::new(12, 10, 24, 3)));
        assert_eq!(projection.row(2), Some(Rect::new(12, 13, 24, 3)));
        assert_eq!(projection.description, Some(Rect::new(12, 16, 24, 1)));
        assert_eq!(projection.total_height, 11);
    }
}