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,
};
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")];
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;
}
let mut hints: Vec<(&'static str, &'static str)> = Vec::new();
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,
);
}
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());
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,
);
}