shuire 0.1.1

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

use crate::{diff::LineKind, state::App, theme::Theme, ui::centered_rect};

const CONTEXT_MAX_LINES: usize = 5;

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

    let title = if app.editing_comment.is_some() {
        " Edit Comment "
    } else {
        " New Comment "
    };

    let bg = theme.bg;
    let block = Block::default()
        .borders(Borders::ALL)
        .title(Span::styled(
            title,
            Style::default()
                .fg(theme.bg)
                .bg(theme.shuire)
                .add_modifier(Modifier::BOLD),
        ))
        .border_style(Style::default().fg(theme.shuire).bg(bg))
        .style(Style::default().bg(bg));

    let inner = block.inner(area);
    f.render_widget(block, area);
    if inner.width == 0 || inner.height == 0 {
        return;
    }

    let body_style = Style::default().fg(theme.context_fg).bg(bg);
    let dim_style = Style::default().fg(theme.dim_fg).bg(bg);
    let caret_style = Style::default().fg(theme.caret_fg).bg(theme.caret_bg);

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

    let loc_label = location_label(app);
    rendered.push(Line::from(Span::styled(loc_label, dim_style)));

    for line in context_lines(app, theme, inner.width as usize) {
        rendered.push(line);
    }

    rendered.push(Line::from(Span::styled(
        "".repeat(inner.width as usize),
        Style::default().fg(theme.shuire_dim).bg(bg),
    )));

    let body_top_row = rendered.len() as u16;
    let (cursor_col, cursor_row) = push_input_body(
        &mut rendered,
        &app.input,
        app.input_cursor,
        body_style,
        caret_style,
    );

    let hint = " [Enter] save   [S-Enter/C-Enter/C-j] newline   [Esc] cancel ";
    let hint_row = inner.height.saturating_sub(1) as usize;
    while rendered.len() < hint_row {
        rendered.push(Line::from(Span::styled("", body_style)));
    }
    if (rendered.len() as u16) < inner.height {
        rendered.push(Line::from(Span::styled(hint, dim_style)));
    }

    f.render_widget(
        Paragraph::new(rendered).style(Style::default().bg(bg)),
        inner,
    );

    let abs_row = inner.y + body_top_row + cursor_row;
    let abs_col = inner.x + cursor_col;
    if abs_row < inner.y + inner.height && abs_col < inner.x + inner.width {
        f.set_cursor_position(Position::new(abs_col, abs_row));
    }
}

fn location_label(app: &App) -> String {
    let Some(file) = app.current() else {
        return String::new();
    };
    let line = file.lines.get(app.cursor_line);
    let lineno = line.and_then(|l| l.new_lineno.or(l.old_lineno));
    match lineno {
        Some(n) => format!(" {}:{} ", file.path, n),
        None => format!(" {} ", file.path),
    }
}

fn context_lines<'a>(app: &App, theme: &Theme, width: usize) -> Vec<Line<'a>> {
    let Some(file) = app.current() else {
        return Vec::new();
    };
    let (lo, hi) = match (app.visual_start, app.comment_line_end) {
        (Some(start), _) => {
            let end = app.cursor_line;
            (start.min(end), start.max(end))
        }
        (None, Some(end)) => (app.cursor_line.min(end), app.cursor_line.max(end)),
        (None, None) => (app.cursor_line, app.cursor_line),
    };

    let targets: Vec<&crate::diff::DiffLine> = file
        .lines
        .iter()
        .enumerate()
        .filter(|(i, l)| {
            *i >= lo
                && *i <= hi
                && matches!(
                    l.kind,
                    LineKind::Added | LineKind::Removed | LineKind::Context,
                )
        })
        .map(|(_, l)| l)
        .collect();

    if targets.is_empty() {
        return Vec::new();
    }

    let (shown, truncated) = if targets.len() > CONTEXT_MAX_LINES {
        (
            &targets[..CONTEXT_MAX_LINES],
            Some(targets.len() - CONTEXT_MAX_LINES),
        )
    } else {
        (&targets[..], None)
    };

    let mut out: Vec<Line<'a>> = Vec::new();
    for l in shown {
        let (marker, fg) = match l.kind {
            LineKind::Added => ("+ ", theme.added_fg),
            LineKind::Removed => ("- ", theme.removed_fg),
            _ => ("  ", theme.context_fg),
        };
        let text = truncate_to_width(&l.text, width.saturating_sub(marker.width()));
        out.push(Line::from(vec![
            Span::styled(marker.to_string(), Style::default().fg(fg).bg(theme.bg)),
            Span::styled(text, Style::default().fg(fg).bg(theme.bg)),
        ]));
    }
    if let Some(extra) = truncated {
        out.push(Line::from(Span::styled(
            format!("  … +{} more lines", extra),
            Style::default().fg(theme.dim_fg).bg(theme.bg),
        )));
    }
    out
}

fn truncate_to_width(text: &str, max: usize) -> String {
    if text.width() <= max {
        return text.to_string();
    }
    let mut out = String::new();
    let mut used = 0;
    for ch in text.chars() {
        let w = ch.to_string().width();
        if used + w + 1 > max {
            break;
        }
        out.push(ch);
        used += w;
    }
    out.push('');
    out
}

fn push_input_body<'a>(
    rendered: &mut Vec<Line<'a>>,
    input: &'a str,
    cursor: usize,
    body_style: Style,
    caret_style: Style,
) -> (u16, u16) {
    let mut cursor_col = 0u16;
    let mut cursor_row = 0u16;
    let mut byte_offset: usize = 0;
    for (i, line_text) in input.split('\n').enumerate() {
        let line_start = byte_offset;
        let line_end = line_start + line_text.len();
        let cursor_in_line = cursor >= line_start && cursor <= line_end;
        if cursor_in_line {
            let local = cursor - line_start;
            let before = &line_text[..local];
            let rest = &line_text[local..];
            let (glyph, after): (std::borrow::Cow<'a, str>, &'a str) = match rest.chars().next() {
                Some(ch) => (ch.to_string().into(), &rest[ch.len_utf8()..]),
                None => (" ".into(), ""),
            };
            cursor_col = before.width() as u16;
            cursor_row = i as u16;
            rendered.push(Line::from(vec![
                Span::styled(before, body_style),
                Span::styled(glyph, caret_style),
                Span::styled(after, body_style),
            ]));
        } else {
            rendered.push(Line::from(Span::styled(line_text, body_style)));
        }
        byte_offset = line_end + 1;
    }
    (cursor_col, cursor_row)
}