rdice-tui 0.1.1

Terminal UI for persistent dice trays powered by rdice-core
Documentation
use std::collections::BTreeMap;

use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::widgets::Paragraph;
use rdice_core::die::{CUSTOM_PREFIX, FaceValue};
use rdice_core::engine::DiceEngine;
use rdice_core::error::Result;

use crate::app::App;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OverviewOptions {
    pub text_visible: bool,
    pub range_visible: bool,
    pub ev_visible: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrayCard {
    pub page_id: usize,
    pub name: String,
    pub selected: bool,
    pub composition: String,
    pub total_line: String,
    pub text_line: Option<String>,
}

pub fn build_tray_cards(
    engine: &DiceEngine,
    selected_trays: &[String],
    page: usize,
    options: OverviewOptions,
) -> Result<Vec<TrayCard>> {
    let start = page * 9;
    let trays = engine.list_trays().iter().skip(start).take(9);
    let mut cards = Vec::new();

    for (offset, tray) in trays.enumerate() {
        let die_names = tray
            .slots
            .iter()
            .map(|slot| slot.die_name.clone())
            .collect::<Vec<_>>();
        let analysis = engine.analyze_roll(&die_names, &[])?;
        let numeric_total = tray
            .slots
            .iter()
            .filter_map(|slot| match &slot.current_value {
                Some(FaceValue::Integer(value)) => Some(*value),
                _ => None,
            })
            .reduce(|acc, value| acc + value);
        let text_values = tray
            .slots
            .iter()
            .filter_map(|slot| match &slot.current_value {
                Some(FaceValue::Text(value)) => Some(value.clone()),
                _ => None,
            })
            .collect::<Vec<_>>();
        let range = options
            .range_visible
            .then_some((analysis.point_range.min, analysis.point_range.max));
        let ev = options.ev_visible.then_some(analysis.expected_value);

        cards.push(TrayCard {
            page_id: offset + 1,
            name: tray.name.clone(),
            selected: selected_trays.iter().any(|selected| selected == &tray.name),
            composition: format_composition(&die_names),
            total_line: format_total_line(
                numeric_total,
                range,
                ev,
                !options.text_visible && !text_values.is_empty(),
            ),
            text_line: (options.text_visible && !text_values.is_empty())
                .then(|| format!("Text:{}", text_values.join(","))),
        });
    }

    Ok(cards)
}

pub fn format_total_line(
    total: Option<i64>,
    range: Option<(i64, i64)>,
    ev: Option<f64>,
    hidden_text: bool,
) -> String {
    let analysis = match (range, ev) {
        (Some((min, max)), Some(ev)) => format!("({min}-{max}~{})", format_ev(ev)),
        (Some((min, max)), None) => format!("({min}-{max})"),
        (None, Some(ev)) => format!("(~{})", format_ev(ev)),
        (None, None) => String::new(),
    };
    let total = total
        .map(|value| value.to_string())
        .unwrap_or_else(|| "-".to_string());
    let suffix = if hidden_text { " +t" } else { "" };
    format!("Total{analysis}:{total}{suffix}")
}

pub fn format_ev(value: f64) -> String {
    format!("{value:.1}")
}

fn format_composition(die_names: &[String]) -> String {
    let mut counts = BTreeMap::<String, usize>::new();
    for die_name in die_names {
        *counts.entry(display_die_name(die_name)).or_default() += 1;
    }

    counts
        .into_iter()
        .map(|(name, count)| {
            if count == 1 {
                name
            } else {
                format!("{count}x{name}")
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

fn display_die_name(name: &str) -> String {
    let stripped = name.strip_prefix(CUSTOM_PREFIX).unwrap_or(name);
    let stripped = if stripped.len() > 1
        && stripped.starts_with('D')
        && stripped[1..].chars().all(|ch| ch.is_ascii_digit())
    {
        stripped.to_ascii_lowercase()
    } else {
        stripped.to_string()
    };
    let mut chars = stripped.chars();
    let prefix = chars.by_ref().take(7).collect::<String>();
    if chars.next().is_some() {
        format!("{prefix}...")
    } else {
        prefix
    }
}

pub fn render(frame: &mut Frame<'_>, area: Rect, app: &App) {
    let options = OverviewOptions {
        text_visible: app.overview_text_visible,
        range_visible: app.overview_range_visible,
        ev_visible: app.overview_ev_visible,
    };
    let cards = build_tray_cards(&app.engine, &app.selected_trays, app.overview_page, options)
        .unwrap_or_default();

    frame.render_widget(Paragraph::new(render_grid_text(&cards, area.width)), area);
}

pub fn render_grid_text(cards: &[TrayCard], width: u16) -> String {
    if cards.is_empty() {
        return "No trays on this page. Press m to manage trays.".to_string();
    }

    let columns = 3_usize;
    let cell_width = ((width as usize).saturating_sub(columns + 1) / columns).max(18);
    let mut output = Vec::new();

    for row_cards in cards.chunks(columns) {
        let border = grid_border(row_cards.len(), cell_width);
        output.push(border.clone());
        for line_index in 0..4 {
            let cells = row_cards
                .iter()
                .map(|card| {
                    let text = card_line(card, line_index, cell_width);
                    pad_cell(&text, cell_width)
                })
                .collect::<Vec<_>>()
                .join("|");
            output.push(format!("|{cells}|"));
        }
        output.push(border.clone());
    }

    output.join("\n")
}

fn grid_border(columns: usize, cell_width: usize) -> String {
    format!(
        "+{}+",
        std::iter::repeat_n("-".repeat(cell_width), columns)
            .collect::<Vec<_>>()
            .join("+")
    )
}

fn card_line(card: &TrayCard, line_index: usize, width: usize) -> String {
    match line_index {
        0 => {
            let marker = if card.selected { " *" } else { "" };
            format!("[{}] {}{}", card.page_id, card.name, marker)
        }
        1 => card.composition.clone(),
        2 => right_align_text_marker(&card.total_line, width),
        3 => card.text_line.clone().unwrap_or_default(),
        _ => String::new(),
    }
}

fn right_align_text_marker(line: &str, width: usize) -> String {
    let Some(base) = line.strip_suffix(" +t") else {
        return line.to_string();
    };
    if base.len() + 3 >= width {
        return line.to_string();
    }
    format!("{base}{}+t", " ".repeat(width - base.len() - 2))
}

fn pad_cell(text: &str, width: usize) -> String {
    let truncated = truncate(text, width);
    format!("{truncated:<width$}")
}

fn truncate(text: &str, width: usize) -> String {
    let mut output = String::new();
    for ch in text.chars() {
        if output.len() + ch.len_utf8() > width {
            break;
        }
        output.push(ch);
    }
    output
}