shuire 0.1.1

Vim-like TUI git diff viewer
use ratatui::{
    Frame,
    layout::{Constraint, Direction, Layout, Rect},
    style::{Modifier, Style},
    text::{Line, Span},
    widgets::Paragraph,
};
use unicode_width::UnicodeWidthStr;

use crate::{
    state::{App, Focus, Mode},
    theme::Theme,
    ui::tree_file_order,
};

/// Which view is active. The hints row shows a different set of keys
/// per view so the reminder is always relevant to what the user can do
/// right now — including cursor-position-dependent actions (dir toggle,
/// fold expansion, focused-comment delete).
fn hints_for(app: &App) -> Vec<(&'static str, &'static str)> {
    if app.show_help {
        return vec![("?/Esc/q", "close")];
    }
    if app.show_comments_list {
        return vec![
            ("j/k", "move"),
            ("Enter", "jump"),
            ("dd", "delete"),
            ("y/Y", "copy"),
            ("Esc/C", "close"),
        ];
    }
    if app.preview_mode {
        return vec![("Esc/q/r", "close")];
    }
    if app.show_revision_selector {
        return vec![("↑/↓", "revision"), ("Enter", "apply"), ("Esc", "cancel")];
    }
    match app.mode {
        Mode::Insert => vec![
            ("Enter", "save"),
            ("S-Enter", "newline"),
            ("C-w", "del word"),
            ("C-u", "clear"),
            ("Esc", "cancel"),
        ],
        Mode::Search => vec![("Enter", "search"), ("Esc", "cancel")],
        Mode::FileFilter => vec![("Enter", "apply"), ("Esc", "cancel")],
        Mode::Visual => vec![("j/k", "extend"), ("i", "comment"), ("Esc", "cancel")],
        Mode::Normal => normal_hints(app),
    }
}

fn normal_hints(app: &App) -> Vec<(&'static str, &'static str)> {
    if app.focus == Focus::Files {
        let mut hints: Vec<(&'static str, &'static str)> = vec![("j/k", "file")];
        // `Enter` / `Space` only do something on directory entries.
        if app.file_tree_cursor_on_dir() {
            hints.push(("Enter/Space", "toggle"));
        }
        hints.extend_from_slice(&[
            ("l", "diff"),
            ("F", "hide"),
            ("/", "filter"),
            ("?", "help"),
            ("q", "quit"),
        ]);
        return hints;
    }

    // Focus::Diff
    let mut hints: Vec<(&'static str, &'static str)> = Vec::new();

    // Lead with cursor-specific actions so the most relevant affordance
    // is visible first even if the row gets truncated on a narrow term.
    if app.comment_focus.is_some() {
        hints.extend_from_slice(&[("i", "edit"), ("dd", "delete"), ("y", "copy")]);
    } else if app.cursor_on_fold() {
        hints.extend_from_slice(&[("Enter/o", "expand"), ("O", "expand all")]);
    }

    if app.search_query.is_some() {
        hints.extend_from_slice(&[
            ("n/p", "match"),
            ("Esc", "clear"),
            ("j/k", "line"),
            ("c/i", "comment"),
            ("?", "help"),
            ("q", "quit"),
        ]);
    } else {
        hints.extend_from_slice(&[
            ("j/k", "line"),
            ("Tab", "file"),
            ("c/i", "comment"),
            ("s", "split"),
            ("/", "search"),
            ("F", "files"),
            ("T", "theme"),
            ("?", "help"),
            ("q", "quit"),
        ]);
    }
    hints
}

pub fn render(f: &mut Frame, app: &App, area: Rect, theme: &Theme) {
    if area.height >= 2 {
        let rows = Layout::default()
            .direction(Direction::Vertical)
            .constraints([Constraint::Length(1), Constraint::Length(1)])
            .split(area);
        render_hints(f, app, rows[0], theme);
        render_status(f, app, rows[1], theme);
    } else {
        render_status(f, app, area, theme);
    }
}

fn render_hints(f: &mut Frame, app: &App, area: Rect, theme: &Theme) {
    let bg = theme.hint_bg;
    let key_style = Style::default()
        .fg(theme.shuire)
        .bg(bg)
        .add_modifier(Modifier::BOLD);
    let label_style = Style::default().fg(theme.hint_fg).bg(bg);
    let sep_style = Style::default().fg(theme.dim_fg).bg(bg);

    let total = area.width as usize;
    let mut spans: Vec<Span> = Vec::new();
    spans.push(Span::styled(" ", label_style));
    let mut used = 1;
    for (i, (k, label)) in hints_for(app).iter().enumerate() {
        let sep_w = if i > 0 { 5 } else { 0 };
        let pair_w = 1 + k.width() + 1 + 1 + label.width();
        if used + sep_w + pair_w > total {
            break;
        }
        if i > 0 {
            spans.push(Span::styled("  ·  ", sep_style));
        }
        spans.push(Span::styled(format!("[{k}]"), key_style));
        spans.push(Span::styled(format!(" {label}"), label_style));
        used += sep_w + pair_w;
    }

    if total > used {
        spans.push(Span::styled(
            " ".repeat(total - used),
            Style::default().bg(bg),
        ));
    }
    f.render_widget(
        Paragraph::new(Line::from(spans)).style(Style::default().bg(bg)),
        area,
    );
}

/// Middle section of the status row. When the hints row is active this
/// carries only contextual info (path, input buffer, selection range);
/// when the hints row is off we fall back to the richer legacy string
/// that embeds both info and hints so single-row users don't lose the
/// key reminders.
fn middle_text(app: &App, file_pos: &str, position: &str) -> String {
    if app.show_comments_list {
        return if app.show_hints {
            " Comment list ".to_string()
        } else {
            " [Esc/q/C]close [j/k]move [Enter]jump ".to_string()
        };
    }
    match app.mode {
        Mode::Normal => {
            if let Some(q) = app.search_query.as_deref() {
                let cur = if app.search_matches.is_empty() {
                    0
                } else {
                    app.search_match_cursor + 1
                };
                let total = app.search_matches.len();
                let info = format!(" /{q} ({cur}/{total}) ");
                if app.show_hints {
                    info
                } else {
                    format!("{info} [n/p]match [Esc]clear ")
                }
            } else {
                let path = app
                    .files
                    .get(app.selected)
                    .map(|f| f.path.clone())
                    .unwrap_or_default();
                format!(" {path}  {file_pos}  {position} ")
            }
        }
        Mode::Insert => {
            if app.show_hints {
                " Composing comment ".to_string()
            } else {
                " [Esc]cancel [Enter]submit [S-Enter/C-Enter/C-j]newline [C-w]word [C-u]clear "
                    .to_string()
            }
        }
        Mode::Search => {
            let buf = format!(" /{}", app.input);
            if app.show_hints {
                buf
            } else {
                format!("{buf}[Enter]search [Esc]cancel ")
            }
        }
        Mode::FileFilter => {
            let buf = format!(" Filter: {}", app.input);
            if app.show_hints {
                buf
            } else {
                format!("{buf}[Enter]apply [Esc]cancel ")
            }
        }
        Mode::Visual => {
            if app.show_hints {
                let start = app.visual_start.unwrap_or(app.cursor_line);
                let end = app.cursor_line;
                let (lo, hi) = if start <= end {
                    (start, end)
                } else {
                    (end, start)
                };
                format!(" Visual L{}-L{} ", lo + 1, hi + 1)
            } else {
                " [j/k]extend [i]comment [Esc]cancel ".to_string()
            }
        }
    }
}

fn render_status(f: &mut Frame, app: &App, area: Rect, theme: &Theme) {
    let position = format!("L{}", app.current_lineno());
    let file_pos = if app.files.is_empty() {
        "0/0".to_string()
    } else {
        let order = tree_file_order(&app.files);
        let pos = order.iter().position(|&i| i == app.selected).unwrap_or(0);
        format!("{}/{}", pos + 1, app.files.len())
    };

    let (mode_name, mode_bg) = if app.show_comments_list {
        ("COMMENTS", theme.mode_comments_bg)
    } else {
        match app.mode {
            Mode::Normal => ("NORMAL", theme.mode_normal_bg),
            Mode::Insert => ("INSERT", theme.mode_insert_bg),
            Mode::Search => ("SEARCH", theme.mode_search_bg),
            Mode::FileFilter => ("FILTER", theme.mode_search_bg),
            Mode::Visual => ("VISUAL", theme.mode_visual_bg),
        }
    };
    let mode_label = format!("{mode_name} ");
    let mode_style = Style::default().fg(theme.mode_fg).bg(mode_bg);

    let (adds, dels) = app
        .files
        .get(app.selected)
        .map(|f| (f.added, f.removed))
        .unwrap_or((0, 0));

    let bar_bg = theme.hint_bg;
    let mode_style_bold = mode_style.add_modifier(Modifier::BOLD);
    let dim = Style::default().fg(theme.hint_fg).bg(bar_bg);
    let status_fg = Style::default().fg(theme.status_fg).bg(bar_bg);

    let middle = middle_text(app, &file_pos, &position);

    let right_stats = format!(" +{adds}{dels} ");
    let right_shu = format!("{} ", app.comments.len());
    // The hints row above already shows `?`/`q`; suppress the trailing
    // reminder in that case to avoid doubling up.
    let right_hints = if app.show_hints {
        ""
    } else {
        " [?] help  [q] quit "
    };

    let mode_label_w = mode_label.width();
    let middle_w = middle.width();
    let right_stats_w = right_stats.width();
    let right_shu_w = right_shu.width();
    let right_hints_w = right_hints.width();

    let total_w = area.width as usize;
    let used = mode_label_w + middle_w + right_stats_w + right_shu_w + right_hints_w;
    let spacer = total_w.saturating_sub(used);

    let mut spans: Vec<Span> = vec![
        Span::styled(mode_label, mode_style_bold),
        Span::styled(middle, dim),
        Span::styled(" ".repeat(spacer), Style::default().bg(bar_bg)),
        Span::styled(
            format!(" +{adds} "),
            Style::default().fg(theme.added_fg).bg(bar_bg),
        ),
        Span::styled(
            format!("{dels} "),
            Style::default().fg(theme.removed_fg).bg(bar_bg),
        ),
        Span::styled(
            right_shu,
            Style::default()
                .fg(theme.shuire)
                .bg(bar_bg)
                .add_modifier(Modifier::BOLD),
        ),
    ];
    if !right_hints.is_empty() {
        spans.push(Span::styled(right_hints, status_fg));
    }
    let status = Line::from(spans).style(Style::default().bg(bar_bg));
    f.render_widget(
        Paragraph::new(status).style(Style::default().bg(bar_bg)),
        area,
    );
}