schemaui 0.6.0

A Rust library for generating TUI and Web UIs from JSON Schemas for configuration management.
Documentation
use ratatui::{
    Frame,
    layout::{Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, Wrap},
};

use super::super::frame::{
    HelpErrorRender, HelpOverlayPage, HelpOverlayRender, HelpShortcutRender,
};

const SIDE_BY_SIDE_MIN_WIDTH: u16 = 110;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct HelpOverlayPanelCapacities {
    pub shortcuts: usize,
    pub errors: usize,
}

pub(crate) fn help_overlay_panel_capacities(area: Rect) -> HelpOverlayPanelCapacities {
    let inner = bordered_inner(area);
    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(3),
            Constraint::Min(8),
            Constraint::Length(1),
        ])
        .split(inner);
    let [shortcut_area, error_area] = split_main_panels(inner, layout[1]);
    HelpOverlayPanelCapacities {
        shortcuts: table_row_capacity(shortcut_area),
        errors: table_row_capacity(error_area),
    }
}

pub(crate) fn help_overlay_error_page_capacity(area: Rect) -> usize {
    help_overlay_panel_capacities(area).errors
}

pub fn render_help_overlay(frame: &mut Frame<'_>, help: HelpOverlayRender<'_>) {
    let area = frame.area();
    frame.render_widget(Clear, area);

    let block = Block::default().title("Help").borders(Borders::ALL).style(
        Style::default()
            .fg(Color::Cyan)
            .add_modifier(Modifier::BOLD),
    );
    frame.render_widget(block.clone(), area);
    let inner = bordered_inner(area);

    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(3),
            Constraint::Min(8),
            Constraint::Length(1),
        ])
        .split(inner);

    let page = help.page;
    render_header(frame, layout[0], page);

    let main = split_main_panels(inner, layout[1]);
    let capacities = help_overlay_panel_capacities(area);

    render_shortcuts_table(
        frame,
        main[0],
        &page.shortcuts,
        help.shortcut_offset,
        capacities.shortcuts,
    );
    render_errors_panel(frame, main[1], page);

    let footer = Paragraph::new(Line::from(vec![
        Span::styled("Esc", Style::default().fg(Color::Yellow)),
        Span::raw(" close  "),
        Span::styled("↑/↓ or j/k", Style::default().fg(Color::Yellow)),
        Span::raw(" scroll shortcuts  "),
        Span::styled("Tab / Shift+Tab", Style::default().fg(Color::Yellow)),
        Span::raw(" switch error page"),
    ]))
    .style(Style::default().fg(Color::Gray));
    frame.render_widget(footer, layout[2]);
}

fn bordered_inner(area: Rect) -> Rect {
    Rect {
        x: area.x.saturating_add(1),
        y: area.y.saturating_add(1),
        width: area.width.saturating_sub(2),
        height: area.height.saturating_sub(2),
    }
}

fn split_main_panels(inner: Rect, area: Rect) -> [Rect; 2] {
    let chunks = if inner.width >= SIDE_BY_SIDE_MIN_WIDTH {
        Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Percentage(62), Constraint::Percentage(38)])
            .split(area)
    } else {
        Layout::default()
            .direction(Direction::Vertical)
            .constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
            .split(area)
    };
    [chunks[0], chunks[1]]
}

fn table_row_capacity(area: Rect) -> usize {
    let inner = bordered_inner(area);
    usize::from(inner.height.saturating_sub(1)).max(1)
}

fn render_header(frame: &mut Frame<'_>, area: Rect, page: &HelpOverlayPage) {
    let header = Paragraph::new(vec![
        Line::from(vec![
            Span::styled(
                "Shortcuts",
                Style::default()
                    .fg(Color::Yellow)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::raw(format!(" {} ", page.shortcuts.len())),
            Span::styled("•", Style::default().fg(Color::DarkGray)),
            Span::raw(format!(" errors {} ", page.total_errors)),
            Span::styled("•", Style::default().fg(Color::DarkGray)),
            Span::raw(format!(
                " error page {}/{}",
                page.current_page, page.total_pages
            )),
        ]),
        Line::from(Span::styled(
            page.summary.as_str(),
            Style::default().fg(Color::Gray),
        )),
    ])
    .wrap(Wrap { trim: true });
    frame.render_widget(header, area);
}

fn render_shortcuts_table(
    frame: &mut Frame<'_>,
    area: Rect,
    shortcuts: &[HelpShortcutRender],
    shortcut_offset: usize,
    visible_rows: usize,
) {
    let max_offset = shortcuts.len().saturating_sub(visible_rows);
    let start = shortcut_offset.min(max_offset);
    let end = (start + visible_rows).min(shortcuts.len());
    let title = if shortcuts.len() <= visible_rows {
        "Shortcuts".to_string()
    } else {
        format!("Shortcuts {}-{} / {}", start + 1, end, shortcuts.len())
    };
    let block = Block::default()
        .title(title)
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::DarkGray));
    let rows = shortcuts[start..end].iter().map(shortcut_row);
    let table = Table::new(
        rows,
        [
            Constraint::Length(7),
            Constraint::Length(18),
            Constraint::Min(24),
        ],
    )
    .header(
        Row::new(vec!["Scope", "Keys", "Action"]).style(
            Style::default()
                .fg(Color::White)
                .add_modifier(Modifier::BOLD),
        ),
    )
    .block(block)
    .column_spacing(1);
    frame.render_widget(table, area);
}

fn shortcut_row(entry: &HelpShortcutRender) -> Row<'static> {
    let scope_style = match entry.scope.as_str() {
        "Form" => Style::default().fg(Color::Cyan),
        "List" => Style::default().fg(Color::Magenta),
        "Overlay" => Style::default().fg(Color::Yellow),
        _ => Style::default().fg(Color::Gray),
    };
    Row::new(vec![
        Cell::from(entry.scope.clone()).style(scope_style),
        Cell::from(entry.keys.clone()).style(
            Style::default()
                .fg(Color::Green)
                .add_modifier(Modifier::BOLD),
        ),
        Cell::from(entry.action.clone()),
    ])
}

fn render_errors_panel(frame: &mut Frame<'_>, area: Rect, page: &HelpOverlayPage) {
    let title = if page.total_errors == 0 {
        "Errors".to_string()
    } else {
        let start = page.errors.first().map(|error| error.index).unwrap_or(0);
        let end = page.errors.last().map(|error| error.index).unwrap_or(0);
        format!("Errors {start}-{end} / {}", page.total_errors)
    };

    let block = Block::default()
        .title(title)
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::DarkGray));
    let inner = block.inner(area);
    frame.render_widget(block, area);

    if page.errors.is_empty() {
        let paragraph = Paragraph::new(vec![
            Line::from(Span::styled(
                "No validation errors.",
                Style::default()
                    .fg(Color::Green)
                    .add_modifier(Modifier::BOLD),
            )),
            Line::from("Current form state is clean."),
        ])
        .wrap(Wrap { trim: true })
        .style(Style::default().fg(Color::Gray));
        frame.render_widget(paragraph, inner);
        return;
    }

    let rows = page.errors.iter().map(error_row);
    let table = Table::new(
        rows,
        [
            Constraint::Length(4),
            Constraint::Length(18),
            Constraint::Min(18),
        ],
    )
    .header(
        Row::new(vec!["#", "Field", "Message"]).style(
            Style::default()
                .fg(Color::White)
                .add_modifier(Modifier::BOLD),
        ),
    )
    .column_spacing(1);
    frame.render_widget(table, inner);
}

fn error_row(entry: &HelpErrorRender) -> Row<'static> {
    Row::new(vec![
        Cell::from(entry.index.to_string()).style(Style::default().fg(Color::Red)),
        Cell::from(entry.pointer.clone()).style(Style::default().fg(Color::Cyan)),
        Cell::from(entry.message.clone()).style(Style::default().fg(Color::White)),
    ])
}