use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use crate::app::App;
use crate::git::{DiffContent, FileStatus};
fn key_label(ch: char) -> String {
if ch == ' ' {
"Space".to_string()
} else {
ch.to_string()
}
}
fn help_section(title: &'static str) -> Line<'static> {
Line::from(Span::styled(
title,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
))
}
fn help_row(key: impl Into<String>, description: impl Into<String>) -> Line<'static> {
Line::from(vec![
Span::styled(
format!("{:<14}", key.into()),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(description.into()),
])
}
pub(super) fn render_help_overlay(frame: &mut Frame<'_>, area: Rect, app: &App) {
let popup_area = centered_rect(72, 72, area);
frame.render_widget(Clear, popup_area);
let block = Block::default().borders(Borders::ALL).title(" Help ");
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
let k = &app.config.keys;
let columns =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(inner);
let left = vec![
help_section("Navigation"),
help_row("j / ↓", "next change"),
help_row("k / ↑", "previous change"),
help_row("J / K", "move one visual row"),
help_row("h / l", "previous / next hunk"),
help_row("g / G", "top / bottom"),
help_row("Ctrl-d/u", "half-page down / up"),
Line::raw(""),
help_section("Review"),
help_row(key_label(k.ask), "ask scar"),
help_row(key_label(k.reject), "reject scar"),
help_row(key_label(k.comment), "free comment scar"),
help_row(key_label(k.revert), "revert hunk"),
help_row(key_label(k.seen), "seen / fold hunk"),
help_row(key_label(k.undo), "undo scar"),
help_row(key_label(k.editor), "open editor"),
];
let right = vec![
help_section("Views"),
help_row("Enter", "file view / back"),
help_row("Tab", "stream / diff"),
help_row(key_label(k.follow), "follow latest"),
help_row(key_label(k.picker), "picker"),
help_row(key_label(k.cursor_placement), "center / top cursor"),
help_row(key_label(k.wrap_toggle), "wrap"),
help_row(key_label(k.line_numbers_toggle), "line numbers"),
Line::raw(""),
help_section("Search"),
help_row(key_label(k.search), "search"),
help_row(key_label(k.search_next), "next match"),
help_row(key_label(k.search_prev), "previous match"),
Line::raw(""),
help_section("Other"),
help_row("? / Esc", "close help"),
help_row("q", "quit"),
];
frame.render_widget(Paragraph::new(left).wrap(Wrap { trim: false }), columns[0]);
frame.render_widget(Paragraph::new(right).wrap(Wrap { trim: false }), columns[1]);
}
fn pad_or_truncate_display(s: &str, target: usize) -> String {
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
let w = UnicodeWidthStr::width(s);
if w <= target {
let pad = target - w;
let mut out = String::with_capacity(s.len() + pad);
out.push_str(s);
for _ in 0..pad {
out.push(' ');
}
return out;
}
let keep = target.saturating_sub(1);
let mut acc = 0usize;
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if acc + cw > keep {
break;
}
acc += cw;
out.push(ch);
}
if target > acc {
out.push('…');
acc += 1;
}
while acc < target {
out.push(' ');
acc += 1;
}
out
}
pub(super) fn render_picker(frame: &mut Frame<'_>, area: Rect, app: &App) {
let popup_area = centered_rect(60, 60, area);
let Some(picker) = &app.picker else { return };
let results = app.picker_results();
frame.render_widget(Clear, popup_area);
let block = Block::default().borders(Borders::ALL).title(format!(
" Files {}/{} ",
results.len(),
app.files.len()
));
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(inner);
let query_area = chunks[0];
let list_area = chunks[1];
let query_line = Line::from(vec![
Span::styled("> ", Style::default().fg(Color::Yellow)),
Span::raw(picker.query.clone()),
]);
frame.render_widget(Paragraph::new(query_line), query_area);
let list_width = list_area.width as usize;
const MTIME_WIDTH: usize = 5; const COUNTS_WIDTH: usize = 10; const GAP: usize = 1;
const GUTTER: usize = 2; let reserved = MTIME_WIDTH + GAP + COUNTS_WIDTH + GAP;
let path_width = list_width.saturating_sub(reserved + GUTTER).max(10);
let items: Vec<ListItem<'_>> = results
.iter()
.map(|&file_idx| {
let file = &app.files[file_idx];
let path_color = match file.status {
FileStatus::Modified => Color::Cyan,
FileStatus::Added => Color::Green,
FileStatus::Deleted => Color::Red,
FileStatus::Untracked => Color::Yellow,
};
let counts = match &file.content {
DiffContent::Binary => "bin".to_string(),
DiffContent::Text(_) => format!("+{} -{}", file.added, file.deleted),
};
let mtime = super::format_mtime(file.mtime);
let path_str = file.path.display().to_string();
let padded_path = pad_or_truncate_display(&path_str, path_width);
let padded_counts = format!("{counts:>width$}", width = COUNTS_WIDTH);
ListItem::new(Line::from(vec![
Span::styled(padded_path, Style::default().fg(path_color)),
Span::raw(" "),
Span::styled(mtime, Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::raw(padded_counts),
]))
})
.collect();
let list = List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.highlight_symbol("▸ ");
let mut state = ListState::default();
if !results.is_empty() {
state.select(Some(picker.cursor.min(results.len() - 1)));
}
frame.render_stateful_widget(list, list_area, &mut state);
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let popup_width = area.width.saturating_mul(percent_x) / 100;
let popup_height = area.height.saturating_mul(percent_y) / 100;
Rect {
x: area.x + area.width.saturating_sub(popup_width) / 2,
y: area.y + area.height.saturating_sub(popup_height) / 2,
width: popup_width,
height: popup_height,
}
}