clap-tui 0.1.3

Auto-generate a TUI from clap commands
Documentation
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::{Paragraph, Widget};

use crate::config::TuiConfig;
use crate::form_editor;
use crate::input::UiState;

use super::fields::FieldRenderModel;
use super::{help, styles};

pub(super) fn render_text_field(
    buffer: &mut Buffer,
    ui: &UiState,
    area: Rect,
    config: &TuiConfig,
    model: &FieldRenderModel<'_>,
) -> Option<(u16, u16)> {
    if model.selected {
        render_textarea_field(
            buffer,
            ui,
            model,
            (model.field_error.is_none())
                .then(|| help::required_empty_prompt(model.arg, model.widget, model.required))
                .flatten(),
            area,
            config,
        )
    } else {
        Paragraph::new(display_lines(model))
            .block(model.block.clone())
            .style(model.fill_style.patch(model.text_style))
            .render(area, buffer);
        None
    }
}

pub(super) fn render_textarea_field(
    buffer: &mut Buffer,
    ui: &UiState,
    model: &FieldRenderModel<'_>,
    placeholder: Option<String>,
    area: Rect,
    config: &TuiConfig,
) -> Option<(u16, u16)> {
    render_textarea_value(buffer, ui, model, &model.value, placeholder, area, config)
}

pub(super) fn render_textarea_value(
    buffer: &mut Buffer,
    ui: &UiState,
    model: &FieldRenderModel<'_>,
    value: &str,
    placeholder: Option<String>,
    area: Rect,
    config: &TuiConfig,
) -> Option<(u16, u16)> {
    let textarea = textarea_value(ui, model, value, placeholder, config);
    (&textarea).render(area, buffer);
    textarea_cursor_position(&textarea, area)
}

fn textarea_value(
    ui: &UiState,
    model: &FieldRenderModel<'_>,
    value: &str,
    placeholder: Option<String>,
    config: &TuiConfig,
) -> ratatui_textarea::TextArea<'static> {
    let editor = form_editor::editor_for_render(ui, model.arg.owner_path(), model.arg, value);
    let mut textarea = editor.to_textarea(editor.selection_anchor());
    textarea.set_block(model.block.clone().style(styles::input(config, true)));
    let base_style = Style::default()
        .fg(model.text_style.fg.unwrap_or(config.theme.text))
        .bg(config.theme.surface_raised);
    textarea.set_style(base_style);
    textarea.set_cursor_line_style(base_style);
    textarea.set_cursor_style(
        Style::default()
            .bg(config.theme.accent)
            .add_modifier(Modifier::BOLD),
    );
    textarea.set_selection_style(
        Style::default()
            .fg(config.theme.text)
            .add_modifier(Modifier::REVERSED),
    );
    if let Some(placeholder) = placeholder {
        textarea.set_placeholder_text(placeholder);
        textarea.set_placeholder_style(styles::placeholder(config));
    }
    textarea
}

pub(super) fn display_lines(model: &FieldRenderModel<'_>) -> Vec<Line<'static>> {
    if model.value.is_empty() {
        return help::required_empty_prompt(model.arg, model.widget, model.required)
            .map_or_else(Vec::new, |placeholder| vec![Line::from(placeholder)]);
    }

    model
        .value
        .lines()
        .map(|line| Line::from(line.to_string()))
        .collect()
}

pub(super) fn textarea_cursor_position(
    textarea: &ratatui_textarea::TextArea<'_>,
    area: Rect,
) -> Option<(u16, u16)> {
    if area.width < 3 || area.height < 3 {
        return None;
    }
    let cursor = textarea.cursor();
    let (row, col) = (cursor.0, cursor.1);
    let inner_w = area.width.saturating_sub(2);
    let inner_h = area.height.saturating_sub(2);
    if inner_w == 0 || inner_h == 0 {
        return None;
    }
    let cursor_row = u16::try_from(row).unwrap_or(u16::MAX);
    let cursor_col = u16::try_from(col).unwrap_or(u16::MAX);
    let top_row = scroll_origin_for_cursor(cursor_row, inner_h);
    let top_col = scroll_origin_for_cursor(cursor_col, inner_w);
    let visible_row = cursor_row.saturating_sub(top_row);
    let visible_col = cursor_col.saturating_sub(top_col);
    if visible_row >= inner_h || visible_col >= inner_w {
        return None;
    }
    let x = area.x.saturating_add(1).saturating_add(visible_col);
    let y = area.y.saturating_add(1).saturating_add(visible_row);
    Some((x, y))
}

fn scroll_origin_for_cursor(cursor: u16, len: u16) -> u16 {
    if len == 0 {
        0
    } else if cursor >= len {
        cursor.saturating_add(1).saturating_sub(len)
    } else {
        0
    }
}