shuire 0.1.1

Vim-like TUI git diff viewer
use ratatui::{
    Frame,
    style::{Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Clear, Paragraph},
};
use unicode_width::UnicodeWidthStr;

use crate::{theme::Theme, ui::centered_rect};

struct HelpSection<'a> {
    title: &'a str,
    shuire: bool,
    items: &'a [(&'a str, &'a str)],
}

pub fn render(f: &mut Frame, theme: &Theme) {
    let area = centered_rect(80, 80, f.area());
    f.render_widget(Clear, area);

    let sections: [HelpSection; 3] = [
        HelpSection {
            title: "Navigation",
            shuire: false,
            items: &[
                ("j / k", "Next / previous line"),
                ("Ctrl-d / u", "Half page down / up"),
                ("Ctrl-f / b", "One page down / up"),
                ("gg / G", "Top / bottom"),
                ("n / p", "Next / prev hunk (search match)"),
                ("N / P", "Next / prev comment"),
                ("h / l", "Focus files / diff"),
                ("Tab / S-Tab", "Next / prev file"),
                ("] / [", "Next / prev file"),
                ("} / {", "Last / first file"),
                (".", "Center cursor"),
            ],
        },
        HelpSection {
            title: "Comments",
            shuire: true,
            items: &[
                ("c / i", "Comment on selected line"),
                ("V → c", "Multi-line select → comment"),
                ("Enter / Ctrl-S", "Save comment"),
                ("S-Enter / C-Enter / C-j", "Newline"),
                ("i", "Edit focused comment"),
                ("dd", "Delete focused comment"),
                ("N / P", "Next / prev comment"),
                ("y / Y", "Copy one / all"),
                ("e", "Open in $EDITOR"),
                ("C", "Comment list"),
            ],
        },
        HelpSection {
            title: "View / search",
            shuire: false,
            items: &[
                ("/", "Search (diff) / filter (files)"),
                ("s", "Toggle side-by-side"),
                ("F", "Toggle file list"),
                ("T", "Cycle theme (classic/washi/sumi)"),
                ("1 / 2 / 3", "Classic / Washi / Sumi direct"),
                (":", "Revision selector"),
                ("< / >", "File list width"),
                ("v", "Toggle file read state"),
                ("R", "Reload"),
                ("?", "This help"),
                ("q / Ctrl-c", "Quit"),
            ],
        },
    ];

    const COLS: usize = 2;
    let inner_w = area.width.saturating_sub(2) as usize;
    let col_w = inner_w / COLS;

    let mut lines: Vec<Line> = Vec::new();

    // Ascii triangle brand (design.md §6.8). Three rows of ╱╲ widening into
    // a pyramid, with the title + tagline floating to the right of the apex.
    let shuire_bold = Style::default()
        .fg(theme.shuire)
        .add_modifier(Modifier::BOLD);
    let shuire_line = Style::default().fg(theme.shuire);
    lines.push(Line::from(vec![
        Span::styled("     ╱╲     ", shuire_bold),
        Span::styled("  朱入レ", shuire_bold),
        Span::styled("  /  shuire  ", Style::default().fg(theme.dim_fg)),
        Span::styled("— git diff review", Style::default().fg(theme.context_fg)),
    ]));
    lines.push(Line::from(Span::styled("    ╱  ╲    ", shuire_line)));
    lines.push(Line::from(Span::styled("   ╱    ╲   ", shuire_line)));
    // The rule sits inside the block's borders, so pad by 2 on each side.
    let rule_w = inner_w.saturating_sub(4);
    lines.push(Line::from(vec![
        Span::raw("  "),
        Span::styled("".repeat(rule_w), Style::default().fg(theme.shuire_dim)),
    ]));
    lines.push(Line::from(""));

    const KEY_W: usize = 14;
    for (row_idx, section_row) in sections.chunks(COLS).enumerate() {
        if row_idx > 0 {
            lines.push(Line::from(""));
        }

        // Section header row.
        let mut header_spans: Vec<Span> = Vec::new();
        for s in section_row {
            let prefix = if s.shuire { "" } else { "  " };
            let color = if s.shuire {
                theme.shuire
            } else {
                theme.context_fg
            };
            let title = format!("{prefix}{title}", title = s.title);
            let pad = col_w.saturating_sub(title.width());
            header_spans.push(Span::styled(
                title,
                Style::default().fg(color).add_modifier(Modifier::BOLD),
            ));
            header_spans.push(Span::raw(" ".repeat(pad)));
        }
        lines.push(Line::from(header_spans));

        // Section divider row.
        let mut div_spans: Vec<Span> = Vec::new();
        for s in section_row {
            let color = if s.shuire {
                theme.shuire_dim
            } else {
                theme.border_unfocused
            };
            let bar_w = col_w.saturating_sub(2);
            div_spans.push(Span::styled("".repeat(bar_w), Style::default().fg(color)));
            div_spans.push(Span::raw("  "));
        }
        lines.push(Line::from(div_spans));

        let max_items = section_row.iter().map(|s| s.items.len()).max().unwrap_or(0);
        for i in 0..max_items {
            let mut row: Vec<Span> = Vec::new();
            for s in section_row {
                let key_color = if s.shuire {
                    theme.shuire
                } else {
                    theme.header_context_fg
                };
                if let Some((k, d)) = s.items.get(i) {
                    // Reserve at least KEY_W for the key column, but extend
                    // for long keys so there's always ≥1 space before the
                    // description and we don't bleed into the next column.
                    let k_w = k.width();
                    let key_used = (k_w + 1).max(KEY_W);
                    let key_text = format!("{k}{}", " ".repeat(key_used - k_w));
                    row.push(Span::styled(
                        key_text,
                        Style::default().fg(key_color).add_modifier(Modifier::BOLD),
                    ));
                    let desc_w = col_w.saturating_sub(key_used);
                    let end = crate::ui::byte_offset_at_width(d, desc_w);
                    let truncated = &d[..end];
                    let used = truncated.width();
                    row.push(Span::styled(
                        truncated.to_string(),
                        Style::default().fg(theme.dialog_body_fg),
                    ));
                    if desc_w > used {
                        row.push(Span::raw(" ".repeat(desc_w - used)));
                    }
                } else {
                    row.push(Span::raw(" ".repeat(col_w)));
                }
            }
            lines.push(Line::from(row));
        }
    }

    lines.push(Line::from(""));
    lines.push(Line::from(Span::styled(
        "  朱入レ (shu-ire) — marking up a manuscript with a red pen.",
        Style::default()
            .fg(theme.dim_fg)
            .add_modifier(Modifier::ITALIC),
    )));

    let block = Block::default()
        .borders(Borders::ALL)
        .title(Span::styled(
            " 朱入レ · Help (? to close) ",
            Style::default()
                .fg(theme.bg)
                .bg(theme.shuire)
                .add_modifier(Modifier::BOLD),
        ))
        .border_style(Style::default().fg(theme.shuire).bg(theme.bg_alt))
        .style(Style::default().bg(theme.bg_alt));
    let paragraph = Paragraph::new(lines).block(block);
    f.render_widget(paragraph, area);
}