schemaui 0.11.0

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

use crate::tui::app::status::READY_STATUS;

use super::super::frame::UiContext;

const FOOTER_BG: Color = Color::Rgb(11, 16, 24);
const ACTION_BG: Color = Color::Rgb(15, 22, 31);
const STATUS_BG: Color = Color::Rgb(9, 13, 20);
const KEY_BG: Color = Color::Rgb(95, 196, 255);
const READY_BG: Color = Color::Rgb(88, 196, 123);
const DIRTY_BG: Color = Color::Rgb(240, 193, 84);
const ERROR_BG: Color = Color::Rgb(235, 102, 102);
const LABEL_BG: Color = Color::Rgb(69, 108, 163);
const ALERT_MAX_CHARS: usize = 72;

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FooterActionItem {
    pub keys: String,
    pub action: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FooterStatusTone {
    Ready,
    Dirty,
    Error,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FooterStatusModel {
    pub tone: FooterStatusTone,
    pub badge: String,
    pub message: String,
    pub meta: Vec<String>,
    pub alert: Option<String>,
    pub session_title: Option<String>,
}

pub(crate) fn footer_action_items(help: Option<&str>) -> Vec<FooterActionItem> {
    let Some(help) = help.map(str::trim).filter(|value| !value.is_empty()) else {
        return Vec::new();
    };

    help.split('')
        .filter_map(|snippet| {
            let trimmed = snippet.trim();
            if trimmed.is_empty() {
                return None;
            }
            let (keys, action) = match trimmed.split_once("->") {
                Some((keys, action)) => (keys.trim(), action.trim()),
                None => (trimmed, ""),
            };
            Some(FooterActionItem {
                keys: keys.to_string(),
                action: action.to_string(),
            })
        })
        .collect()
}

pub(crate) fn footer_status_model(ctx: &UiContext<'_>) -> FooterStatusModel {
    let tone = if ctx.error_count > 0 {
        FooterStatusTone::Error
    } else if ctx.dirty {
        FooterStatusTone::Dirty
    } else {
        FooterStatusTone::Ready
    };

    let badge = match tone {
        FooterStatusTone::Error => format!("ERR {}", ctx.error_count),
        FooterStatusTone::Dirty => "DIRTY".to_string(),
        FooterStatusTone::Ready => "READY".to_string(),
    };

    let mut meta = Vec::new();
    if ctx.error_count > 0 && ctx.dirty {
        meta.push("Unsaved changes".to_string());
    }
    if let Some(label) = &ctx.focus_label {
        meta.push(format!("Focus {label}"));
    }

    let alert = ctx
        .global_errors
        .first()
        .map(|message| truncate_text(message, ALERT_MAX_CHARS))
        .filter(|message| !message.is_empty());

    FooterStatusModel {
        tone,
        badge,
        message: compact_status_message(ctx.status_message),
        meta,
        alert,
        session_title: ctx
            .session_title
            .map(str::trim)
            .filter(|title| !title.is_empty())
            .map(str::to_string),
    }
}

pub fn render_footer(frame: &mut Frame<'_>, area: Rect, ctx: &UiContext<'_>) {
    frame.render_widget(Block::default().style(Style::default().bg(FOOTER_BG)), area);
    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(2), Constraint::Length(2)])
        .split(area);

    let actions = footer_action_items(ctx.help);
    let action_line = build_action_line(&actions);
    let action_widget = Paragraph::new(action_line)
        .wrap(Wrap { trim: true })
        .style(Style::default().bg(ACTION_BG).fg(Color::Gray));
    frame.render_widget(action_widget, rows[0]);

    let status = footer_status_model(ctx);
    let status_chunks = split_status_row(rows[1], status.session_title.as_deref());

    let status_line = build_status_line(&status);
    let status_widget = Paragraph::new(status_line)
        .wrap(Wrap { trim: true })
        .style(Style::default().bg(STATUS_BG).fg(Color::White));
    frame.render_widget(status_widget, status_chunks[0]);

    if let (Some(title), Some(title_area)) = (status.session_title.as_deref(), status_chunks.get(1))
    {
        let title_widget = Paragraph::new(Line::from(vec![footer_chip(
            format!(" {} ", truncate_text(title, title_char_budget(*title_area))),
            Style::default()
                .fg(Color::Black)
                .bg(LABEL_BG)
                .add_modifier(Modifier::BOLD),
        )]))
        .alignment(Alignment::Right)
        .style(Style::default().bg(STATUS_BG));
        frame.render_widget(title_widget, *title_area);
    }
}

fn build_action_line(actions: &[FooterActionItem]) -> Line<'static> {
    let mut spans = vec![
        footer_chip(
            " SHORTCUTS ",
            Style::default().fg(Color::White).bg(LABEL_BG),
        ),
        Span::raw(" "),
    ];

    if actions.is_empty() {
        spans.push(Span::styled(
            "Context shortcuts update with the current focus.",
            Style::default().fg(Color::Gray),
        ));
        return Line::from(spans);
    }

    for (idx, item) in actions.iter().enumerate() {
        if idx > 0 {
            spans.push(Span::styled("", Style::default().fg(Color::DarkGray)));
        }
        spans.push(footer_chip(
            format!(" {} ", item.keys),
            Style::default()
                .fg(Color::Black)
                .bg(KEY_BG)
                .add_modifier(Modifier::BOLD),
        ));
        if !item.action.is_empty() {
            spans.push(Span::raw(" "));
            spans.push(Span::styled(
                item.action.clone(),
                Style::default()
                    .fg(Color::White)
                    .add_modifier(Modifier::BOLD),
            ));
        }
    }

    Line::from(spans)
}

fn build_status_line(status: &FooterStatusModel) -> Line<'static> {
    let badge_style = match status.tone {
        FooterStatusTone::Ready => Style::default()
            .fg(Color::Black)
            .bg(READY_BG)
            .add_modifier(Modifier::BOLD),
        FooterStatusTone::Dirty => Style::default()
            .fg(Color::Black)
            .bg(DIRTY_BG)
            .add_modifier(Modifier::BOLD),
        FooterStatusTone::Error => Style::default()
            .fg(Color::Black)
            .bg(ERROR_BG)
            .add_modifier(Modifier::BOLD),
    };

    let mut spans = vec![
        footer_chip(format!(" {} ", status.badge), badge_style),
        Span::raw(" "),
        Span::styled(
            status.message.clone(),
            Style::default()
                .fg(Color::White)
                .add_modifier(Modifier::BOLD),
        ),
    ];

    for entry in &status.meta {
        spans.push(Span::styled("", Style::default().fg(Color::DarkGray)));
        spans.push(Span::styled(
            entry.clone(),
            Style::default().fg(Color::Cyan),
        ));
    }

    if let Some(alert) = &status.alert {
        spans.push(Span::styled("", Style::default().fg(Color::DarkGray)));
        spans.push(footer_chip(
            " ALERT ",
            Style::default()
                .fg(Color::Black)
                .bg(ERROR_BG)
                .add_modifier(Modifier::BOLD),
        ));
        spans.push(Span::raw(" "));
        spans.push(Span::styled(
            alert.clone(),
            Style::default().fg(Color::LightRed),
        ));
    }

    Line::from(spans)
}

fn footer_chip(text: impl Into<String>, style: Style) -> Span<'static> {
    Span::styled(text.into(), style)
}

fn compact_status_message(message: &str) -> String {
    let trimmed = message.trim();
    if trimmed.is_empty() || trimmed == READY_STATUS {
        return "Ready for input".to_string();
    }
    trimmed.to_string()
}

fn truncate_text(input: &str, max_chars: usize) -> String {
    if max_chars == 0 {
        return String::new();
    }

    let char_count = input.chars().count();
    if char_count <= max_chars {
        return input.to_string();
    }

    let mut result = input
        .chars()
        .take(max_chars.saturating_sub(1))
        .collect::<String>();
    result.push('');
    result
}

fn split_status_row(area: Rect, session_title: Option<&str>) -> Vec<Rect> {
    let Some(title) = session_title else {
        return vec![area];
    };

    let title_width = title_display_width(title, area.width);
    if title_width == 0 {
        return vec![area];
    }

    Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Min(12), Constraint::Length(title_width)])
        .split(area)
        .to_vec()
}

fn title_display_width(title: &str, total_width: u16) -> u16 {
    if total_width <= 12 {
        return 0;
    }

    let max_title_width = total_width.saturating_sub(12).min(38);
    if max_title_width < 10 {
        return 0;
    }

    let clipped = truncate_text(title, max_title_width.saturating_sub(2) as usize);
    clipped.chars().count().saturating_add(2) as u16
}

fn title_char_budget(area: Rect) -> usize {
    area.width.saturating_sub(2).clamp(8, 36) as usize
}