makeover-tui 0.49.0

The terminal renderer for makeover-layout, on ratatui. Colour stops being the constraint above 256 entries; geometry never does, because an edge occupies a whole cell on every side.
Documentation
//! A described table drawn into a buffer and written out as HTML.
//!
//! The render a look change to `table` is judged by: one record table and one
//! code table, every column kind, a selected row, at the fidelity asked for.
//! The page is painted first, the way a host paints it, so the table is seen on
//! what it sits on.
//!
//! `cargo run -q --example table_specimen --features theme -- akari-night 256 > night.html`
//!
//! Each line is a `div.ln` of spans; the page embedding it sets the cell box:
//! `.ln { height: 1.25em; white-space: pre } .ln span { display: inline-block;
//! height: 100%; vertical-align: top }`.

use makeover_layout::{Act, Column, ColumnKind, Priority, Sort, Token, Tone, Width};
use makeover_tui::piece::{self, PieceStyle};
use makeover_tui::table::{self, Cell, Sizing, TableStyle};
use makeover_tui::{Fidelity, Theme};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{StatefulWidget, TableState, Widget};
use std::fmt::Write as _;

const WIDTH: u16 = 84;

fn column(
    name: &'static str,
    width: Width,
    priority: Priority,
    kind: ColumnKind,
) -> Column<'static> {
    let mut column = Column::new(name);
    column.width = width;
    column.priority = priority;
    column.kind = kind;
    column
}

fn main() {
    let mut args = std::env::args().skip(1);
    let id = args.next().unwrap_or_else(|| "akari-night".into());
    let fidelity = match args.next().as_deref() {
        Some("16") => Fidelity::Ansi16,
        Some("256") => Fidelity::Ansi256,
        _ => Fidelity::TrueColor,
    };

    let dir = makeover::bundled_themes_dir().expect("makeover ships themes");
    let colors = makeover::load_theme(&[(dir, false)], &id).expect("bundled theme loads");
    let theme = Theme::from_theme(&colors)
        .expect("theme resolves")
        .for_terminal(fidelity);
    let pieces = PieceStyle::from_theme(&theme);
    let style = TableStyle::from_theme(&theme);

    let mut updated = column(
        "Updated",
        Width::Content,
        Priority::Secondary,
        ColumnKind::Date,
    );
    updated.sortable = true;
    updated.sorted = Some(Sort::Descending);
    let mut title = column("Title", Width::Fill, Priority::Essential, ColumnKind::Text);
    title.sortable = true;
    let records = [
        column(
            "Id",
            Width::Content,
            Priority::Optional,
            ColumnKind::Identifier,
        ),
        title,
        updated,
        column(
            "Status",
            Width::Content,
            Priority::Secondary,
            ColumnKind::Status,
        ),
        column(
            "Size",
            Width::Content,
            Priority::Optional,
            ColumnKind::Number,
        ),
        column("", Width::Content, Priority::Essential, ColumnKind::Actions),
    ];

    let status = |label: &str, tone| piece::token(&pieces, label, Token::Badge, tone, false, false);
    let actions = || {
        let mut line = piece::act(&pieces, &Act::new("Edit"), false);
        line.spans.push(" ".into());
        line.spans.extend(
            piece::act(
                &pieces,
                &Act {
                    tone: Tone::Danger,
                    ..Act::new("Delete")
                },
                false,
            )
            .spans,
        );
        line
    };
    let data = [
        (
            "a3f9c1",
            "Field recordings, volume two",
            "2026-09-11",
            "published",
            Tone::Success,
            "1.4 GB",
        ),
        (
            "7be204",
            "Stems for the October single",
            "2026-09-10",
            "draft",
            Tone::Neutral,
            "812 MB",
        ),
        (
            "0c55de",
            "Liner notes",
            "2026-09-08",
            "processing",
            Tone::Info,
            "44 KB",
        ),
        (
            "e91a07",
            "Live at the Mercury, full set",
            "2026-09-02",
            "failed",
            Tone::Danger,
            "3.1 GB",
        ),
        (
            "5d0f3b",
            "Artwork, square and wide",
            "2026-08-29",
            "needs review",
            Tone::Warning,
            "18 MB",
        ),
        (
            "b41c88",
            "Press kit",
            "2026-08-21",
            "published",
            Tone::Success,
            "6 MB",
        ),
    ];
    let rows: Vec<Vec<Cell<'_>>> = data
        .iter()
        .map(|(id, name, date, label, tone, size)| {
            vec![
                Cell::new("Id", *id),
                Cell::new("Title", *name),
                Cell::new("Updated", *date),
                Cell::new("Status", status(label, *tone)).part(makeover_layout::CellPart::Tokens),
                Cell::new("Size", *size),
                Cell::new("", actions()).part(makeover_layout::CellPart::Actions),
            ]
        })
        .collect();

    let code_columns = [
        column(
            "Line",
            Width::Content,
            Priority::Essential,
            ColumnKind::Number,
        ),
        column("Source", Width::Fill, Priority::Essential, ColumnKind::Code),
    ];
    let source = [
        "fn main() {",
        "    let theme = Theme::from_theme(&colors)?;",
        "    let style = TableStyle::from_theme(&theme);",
        "    draw(&style);",
        "}",
    ];
    let code_rows: Vec<Vec<Cell<'_>>> = source
        .iter()
        .enumerate()
        .map(|(n, line)| {
            vec![
                Cell::new("Line", format!("{}", n + 1)),
                Cell::new("Source", *line),
            ]
        })
        .collect();

    let sizing = Sizing {
        lengths: &[("Title", 16), ("Source", 20)],
        fallback: 8,
    };
    let record_height = u16::try_from(rows.len()).unwrap() + 1;
    let code_height = u16::try_from(code_rows.len()).unwrap() + 1;
    let height = 1 + record_height + 2 + code_height + 1;
    let area = Rect::new(0, 0, WIDTH, height);
    let mut buf = Buffer::empty(area);
    buf.set_style(
        area,
        Style::new()
            .bg(theme.surface_page)
            .fg(theme.content_primary),
    );

    let inset = |y, h| Rect::new(2, y, WIDTH - 4, h);
    let mut state = TableState::default().with_selected(Some(2));
    let records_at = inset(1, record_height);
    StatefulWidget::render(
        table::table(&records, &rows, &sizing, &style, records_at.width),
        records_at,
        &mut buf,
        &mut state,
    );
    let code_at = inset(1 + record_height + 2, code_height);
    Widget::render(
        table::table(&code_columns, &code_rows, &sizing, &style, code_at.width),
        code_at,
        &mut buf,
    );

    print!(
        "{}",
        html(&buf, fidelity, theme.surface_page, theme.content_primary)
    );
}

fn hex(color: Color, fidelity: Fidelity, fallback: Color) -> String {
    let rgb = |c: makeover::Rgb| format!("#{:02x}{:02x}{:02x}", c.r, c.g, c.b);
    match color {
        Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"),
        Color::Indexed(i) if fidelity == Fidelity::Ansi16 => rgb(makeover::ANSI_16[usize::from(i)]),
        Color::Indexed(i) => rgb(makeover::ANSI_240[usize::from(i) - makeover::ANSI_240_OFFSET]),
        _ => hex(fallback, fidelity, fallback),
    }
}

fn html(buf: &Buffer, fidelity: Fidelity, page: Color, ink: Color) -> String {
    // A cell is a box one `ch` wide and one line tall, so a background fills the
    // whole cell the way a terminal fills it and no gap opens between rows. The
    // half blocks are painted rather than set in a font: a browser's glyph
    // covers its em box and not the line, and a terminal draws these itself.
    let mut out = String::new();
    for y in 0..buf.area.height {
        out.push_str("<div class=\"ln\">");
        let mut run: Option<(String, String)> = None;
        for x in 0..buf.area.width {
            let cell = &buf[(x, y)];
            let (mut fg, mut bg) = (hex(cell.fg, fidelity, ink), hex(cell.bg, fidelity, page));
            if cell.modifier.contains(Modifier::REVERSED) {
                std::mem::swap(&mut fg, &mut bg);
            }
            let half = match cell.symbol() {
                "\u{258C}" => Some("to right"),
                "\u{2590}" => Some("to left"),
                "\u{2580}" => Some("to bottom"),
                "\u{2584}" => Some("to top"),
                _ => None,
            };
            if let Some(toward) = half {
                if let Some((open, text)) = run.take() {
                    let _ = write!(out, "<span style=\"{open}\">{text}</span>");
                }
                let _ = write!(
                    out,
                    "<span style=\"background:linear-gradient({toward},{fg} 50%,{bg} 50%)\"> </span>"
                );
                continue;
            }
            let mut css = format!("color:{fg};background:{bg}");
            if cell.modifier.contains(Modifier::BOLD) {
                css.push_str(";font-weight:700");
            }
            if cell.modifier.contains(Modifier::UNDERLINED) {
                css.push_str(";text-decoration:underline");
            }
            let symbol = match cell.symbol() {
                "<" => "&lt;",
                ">" => "&gt;",
                "&" => "&amp;",
                s => s,
            };
            match &mut run {
                Some((open, text)) if *open == css => text.push_str(symbol),
                _ => {
                    if let Some((open, text)) = run.take() {
                        let _ = write!(out, "<span style=\"{open}\">{text}</span>");
                    }
                    run = Some((css, symbol.to_owned()));
                }
            }
        }
        if let Some((open, text)) = run {
            let _ = write!(out, "<span style=\"{open}\">{text}</span>");
        }
        out.push_str("</div>\n");
    }
    out
}