use crate::app::{App, ViewMode, DIFF_VIEW_MIN_WIDTH, FILE_PANEL_MIN_WIDTH};
use crate::color;
use crate::views::{render_blame, render_evolution, render_split, render_unified_pane};
use oyo_core::{multi::DiffStatus, FileStatus};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{
Block, Borders, Clear, List, ListItem, ListState, Paragraph, Scrollbar,
ScrollbarOrientation, ScrollbarState,
},
Frame,
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
fn truncate_filename_keep_ext(name: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
if name.len() <= max_width {
return name.to_string();
}
if max_width <= 3 {
return ".".repeat(max_width);
}
let (stem, ext) = match name.rfind('.') {
Some(idx) if idx > 0 && idx < name.len().saturating_sub(1) => (&name[..idx], &name[idx..]),
_ => (name, ""),
};
let ext_len = ext.len();
if ext_len >= max_width {
let suffix_len = max_width.saturating_sub(3);
return format!("…{}", &name[name.len().saturating_sub(suffix_len)..]);
}
if ext_len == 0 {
let stem_keep = max_width.saturating_sub(3);
let head_len = stem_keep.div_ceil(2);
let tail_len = stem_keep.saturating_sub(head_len);
let head = &stem[..head_len.min(stem.len())];
let tail = if tail_len > 0 && tail_len <= stem.len() {
&stem[stem.len().saturating_sub(tail_len)..]
} else {
""
};
return format!("{head}…{tail}");
}
let max_stem_len = max_width.saturating_sub(ext_len);
if max_stem_len <= 3 {
let dots = ".".repeat(max_stem_len);
return format!("{dots}{ext}");
}
let stem_keep = max_stem_len.saturating_sub(3);
let head_len = stem_keep.div_ceil(2);
let tail_len = stem_keep.saturating_sub(head_len);
let head = &stem[..head_len.min(stem.len())];
let tail = if tail_len > 0 && tail_len <= stem.len() {
&stem[stem.len().saturating_sub(tail_len)..]
} else {
""
};
format!("{head}…{tail}{ext}")
}
fn text_width(text: &str) -> usize {
UnicodeWidthStr::width(text)
}
fn spans_width(spans: &[Span]) -> usize {
spans
.iter()
.map(|span| text_width(span.content.as_ref()))
.sum()
}
fn truncate_to_width(text: &str, max_width: usize) -> String {
let mut out = String::new();
let mut width = 0usize;
for ch in text.chars() {
let ch_width = text_width(&ch.to_string());
if width + ch_width > max_width {
break;
}
out.push(ch);
width += ch_width;
}
out
}
fn wrap_editor_line(line: &str, max_width: usize) -> Vec<String> {
if max_width == 0 {
return vec![String::new()];
}
if line.is_empty() {
return vec![String::new()];
}
let mut out: Vec<String> = Vec::new();
let mut current = String::new();
let mut width = 0usize;
for ch in line.chars() {
let ch_width = ch.width().unwrap_or(1).max(1);
if width + ch_width > max_width && !current.is_empty() {
out.push(current);
current = String::new();
width = 0;
}
current.push(ch);
width += ch_width;
if width >= max_width {
out.push(current);
current = String::new();
width = 0;
}
}
if !current.is_empty() {
out.push(current);
}
if out.is_empty() {
out.push(String::new());
}
out
}
fn editor_cursor_visual(line: &str, cursor_col: usize, max_width: usize) -> (usize, usize) {
if max_width == 0 || line.is_empty() {
return (0, 0);
}
let mut row = 0usize;
let mut col = 0usize;
for (idx, ch) in line.chars().enumerate() {
if idx >= cursor_col {
break;
}
let ch_width = ch.width().unwrap_or(1).max(1);
if col + ch_width > max_width && col > 0 {
row += 1;
col = 0;
}
col += ch_width;
if col == max_width {
row += 1;
col = 0;
}
}
(row, col)
}
fn format_ratio(current: usize, total: usize) -> String {
let width = total.to_string().len();
let current_padded = format!("{:>width$}", current, width = width);
format!("{}/{}", current_padded, total)
}
fn diff_spinner_frame() -> &'static str {
const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let idx = (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
/ 100)
% FRAMES.len() as u128;
FRAMES[idx as usize]
}
fn clamp_spans_to_width<'a>(spans: &[Span<'a>], max_width: usize) -> Vec<Span<'a>> {
let mut out = Vec::new();
let mut remaining = max_width;
for span in spans {
if remaining == 0 {
break;
}
let width = text_width(span.content.as_ref());
if width <= remaining {
out.push(span.clone());
remaining -= width;
} else {
let truncated = truncate_to_width(span.content.as_ref(), remaining);
if !truncated.is_empty() {
out.push(Span::styled(truncated, span.style));
}
break;
}
}
out
}
fn pad_spans_left(spans: Vec<Span>, width: usize) -> Vec<Span> {
let current = spans_width(&spans);
if current >= width {
return spans;
}
let mut out = spans;
out.push(Span::raw(" ".repeat(width - current)));
out
}
fn pad_spans_center(spans: Vec<Span>, width: usize) -> Vec<Span> {
let current = spans_width(&spans);
if current >= width {
return spans;
}
let remaining = width - current;
let left = remaining / 2;
let right = remaining - left;
let mut out = Vec::new();
if left > 0 {
out.push(Span::raw(" ".repeat(left)));
}
out.extend(spans);
if right > 0 {
out.push(Span::raw(" ".repeat(right)));
}
out
}
fn pad_spans_right(spans: Vec<Span>, width: usize) -> Vec<Span> {
let current = spans_width(&spans);
if current >= width {
return spans;
}
let mut out = Vec::new();
out.push(Span::raw(" ".repeat(width - current)));
out.extend(spans);
out
}
fn truncate_path(path: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
if path.len() <= max_width {
return path.to_string();
}
if max_width <= 3 {
return ".".repeat(max_width);
}
let parts: Vec<&str> = path.split('/').collect();
if parts.len() == 1 {
return truncate_filename_keep_ext(path, max_width);
}
let first = parts[0];
let last = parts.last().unwrap_or(&"");
let prefix = format!("{}/…/", first);
let available = max_width.saturating_sub(prefix.len());
if available > 0 {
let last_display = truncate_filename_keep_ext(last, available);
let simple = format!("{prefix}{last_display}");
if simple.len() <= max_width {
return simple;
}
}
if max_width <= 4 {
return ".".repeat(max_width);
}
let prefix = "…/";
let available = max_width.saturating_sub(prefix.len());
if available == 0 {
return ".".repeat(max_width);
}
let last_display = truncate_filename_keep_ext(last, available);
format!("{prefix}{last_display}")
}
fn truncate_text(text: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
if max_width <= 3 {
return ".".repeat(max_width);
}
if text.len() <= max_width {
return text.to_string();
}
let mut acc = String::new();
let mut width = 0usize;
for ch in text.chars() {
let ch_width = UnicodeWidthStr::width(ch.to_string().as_str());
if width + ch_width > max_width.saturating_sub(3) {
break;
}
acc.push(ch);
width += ch_width;
}
format!("{acc}…")
}
pub fn draw(frame: &mut Frame, app: &mut App) {
app.clear_review_preview_boxes();
if app.zen_mode {
draw_content(frame, app, frame.area(), false);
draw_zen_progress(frame, app);
} else {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(if app.topbar {
vec![
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ]
} else {
vec![
Constraint::Min(0), Constraint::Length(1), ]
})
.split(frame.area());
if app.topbar {
draw_content(frame, app, chunks[1], true);
draw_status_bar(frame, app, chunks[2]);
} else {
draw_content(frame, app, chunks[0], false);
draw_status_bar(frame, app, chunks[1]);
}
}
if app.show_help {
draw_help_popover(frame, app);
}
if app.show_path_popup {
draw_path_popup(frame, app);
}
if app.command_palette_active() {
draw_command_palette_popover(frame, app);
}
if app.file_search_active() {
draw_file_search_popover(frame, app);
}
if app.review_mode() {
if app.review_editor_active() {
app.clear_review_preview_boxes();
draw_review_editor_overlay(frame, app);
} else if !matches!(
app.view_mode,
ViewMode::UnifiedPane | ViewMode::Split | ViewMode::Evolution | ViewMode::Blame
) {
draw_review_comment_overlays(frame, app);
}
} else {
app.clear_review_preview_boxes();
}
}
fn draw_status_bar(frame: &mut Frame, app: &mut App, area: Rect) {
let state = app.state();
let (insertions, deletions) = app.stats();
let mode = match app.view_mode {
ViewMode::UnifiedPane => " UNIFIED ",
ViewMode::Split => " SPLIT ",
ViewMode::Evolution => " EVOLUTION ",
ViewMode::Blame => " BLAME ",
};
let file_path = app.current_file_path();
let available_width = area.width as usize;
let file_name = file_path.rsplit('/').next().unwrap_or(&file_path);
let scope_full = if let Some(branch) = app.git_branch.as_ref() {
format!("{}@{}", file_path, branch)
} else {
file_path.clone()
};
let scope_short = if let Some(branch) = app.git_branch.as_ref() {
format!("{}@{}", file_name, branch)
} else {
file_name.to_string()
};
let step_current = state.current_step + 1;
let step_total = state.total_steps;
let step_text = format!("{}/{}", step_current, step_total);
let (arrow_style, step_style) = if app.autoplay {
#[allow(clippy::manual_is_multiple_of)]
let flash = (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
/ 500)
% 2
== 0;
if flash {
(
Style::default().fg(app.theme.warning),
Style::default().fg(app.theme.warning),
)
} else {
(
Style::default().fg(app.theme.warning_dim()),
Style::default().fg(app.theme.warning_dim()),
)
}
} else {
(
Style::default().fg(app.theme.text_muted),
Style::default().fg(app.theme.text),
)
};
let (current_hunk, total_hunks) = app.hunk_info();
let hunk_text = if total_hunks > 0 {
Some(format_ratio(current_hunk, total_hunks))
} else {
None
};
let hunk_step_text = if app.stepping {
app.hunk_step_info().and_then(|(current, total)| {
if current > 0 {
Some(format_ratio(current, total))
} else {
None
}
})
} else {
None
};
let file_count = app.multi_diff.file_count();
let current_file = app.multi_diff.selected_index + 1;
let file_text = format!("{}/{}", current_file, file_count);
let mut center_spans = Vec::new();
let show_goto = app.goto_active();
let show_search = app.search_active();
if show_goto {
center_spans.push(Span::styled(":", Style::default().fg(app.theme.text_muted)));
center_spans.push(Span::raw(" "));
let query = app.goto_query();
let query_text = if app.goto_active() && query.is_empty() {
"Go to".to_string()
} else {
query.to_string()
};
let query_style = if app.goto_active() && query.is_empty() {
Style::default().fg(app.theme.text_muted)
} else {
Style::default().fg(app.theme.text)
};
center_spans.push(Span::styled(query_text, query_style));
} else if show_search {
center_spans.push(Span::styled("/", Style::default().fg(app.theme.text_muted)));
center_spans.push(Span::raw(" "));
let query = app.search_query();
let query_text = if app.search_active() && query.is_empty() {
"Search".to_string()
} else {
query.to_string()
};
let query_style = if app.search_active() && query.is_empty() {
Style::default().fg(app.theme.text_muted)
} else {
Style::default().fg(app.theme.text)
};
center_spans.push(Span::styled(query_text, query_style));
} else if app.stepping {
let autoplay_marker = if app.autoplay {
if app.autoplay_reverse {
"◀"
} else {
"▶"
}
} else {
" "
};
center_spans.push(Span::styled(autoplay_marker, arrow_style));
center_spans.push(Span::raw(" "));
center_spans.push(Span::styled(
"step ",
Style::default().fg(app.theme.text_muted),
));
center_spans.push(Span::styled(step_text.clone(), step_style));
}
let diff_pending = matches!(
app.multi_diff.current_file_diff_status(),
DiffStatus::Deferred | DiffStatus::Computing
) || app.view_build_pending()
|| app.syntax_warmup_pending();
let stats_known = insertions > 0 || deletions > 0;
let mut right_spans = Vec::new();
if let Some(ref hunk) = hunk_text {
let hunk_label = if let Some(ref hunk_step) = hunk_step_text {
format!("{} {}", hunk_step, hunk)
} else {
hunk.to_string()
};
right_spans.push(Span::styled(
hunk_label,
Style::default().fg(app.theme.text_muted),
));
right_spans.push(Span::raw(" "));
}
let spinner = if diff_pending {
diff_spinner_frame()
} else {
" "
};
right_spans.push(Span::styled(
spinner,
Style::default().fg(app.theme.text_muted),
));
right_spans.push(Span::raw(" "));
if diff_pending && !stats_known {
right_spans.push(Span::styled(
"diffing…",
Style::default().fg(app.theme.text_muted),
));
} else {
right_spans.push(Span::styled(
format!("+{}", insertions),
Style::default().fg(app.theme.success),
));
right_spans.push(Span::raw(" "));
right_spans.push(Span::styled(
format!("-{}", deletions),
Style::default().fg(app.theme.error),
));
}
if app.files_changed_on_disk {
right_spans.push(Span::raw(" "));
right_spans.push(Span::styled(
"changed",
Style::default().fg(app.theme.warning),
));
}
let comment_count = app.review_comment_count();
if comment_count > 0 || app.review_editor_active() {
right_spans.push(Span::raw(" "));
let comments_label = match comment_count {
0 => "no comment".to_string(),
1 => "1 comment".to_string(),
n => format!("{n} comments"),
};
right_spans.push(Span::styled(
comments_label,
Style::default().fg(app.theme.primary),
));
}
right_spans.push(Span::raw(" "));
right_spans.push(Span::styled(
format!("file {}", file_text),
Style::default().fg(app.theme.text_muted),
));
right_spans.push(Span::raw(" "));
let left_width = (available_width * 4) / 10;
let center_width = (available_width * 2) / 10;
let right_width = available_width.saturating_sub(left_width + center_width);
let left_fixed_width = text_width(mode) + 1;
let path_max_width = left_width.saturating_sub(left_fixed_width);
let scope_base = if available_width < 60 {
scope_short
} else {
scope_full
};
let display_scope = truncate_path(&scope_base, path_max_width);
let left_spans = vec![
Span::styled(
mode,
Style::default()
.fg(app.theme.background.unwrap_or(Color::Black))
.bg(app.theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(display_scope, Style::default().fg(app.theme.text_muted)),
];
let left_spans = clamp_spans_to_width(&left_spans, left_width);
let left_spans = pad_spans_left(left_spans, left_width);
let center_spans = clamp_spans_to_width(¢er_spans, center_width);
let center_spans = pad_spans_center(center_spans, center_width);
let right_spans = clamp_spans_to_width(&right_spans, right_width);
let right_spans = pad_spans_right(right_spans, right_width);
let mut spans = Vec::new();
spans.extend(left_spans);
spans.extend(center_spans);
spans.extend(right_spans);
let status_line = Line::from(spans);
let mut paragraph = Paragraph::new(status_line);
if let Some(bg) = app.theme.background {
paragraph = paragraph.style(Style::default().bg(bg));
}
frame.render_widget(paragraph, area);
}
fn draw_top_bar(frame: &mut Frame, app: &mut App, area: Rect) {
let (insertions, deletions) = app.stats();
let file = app.multi_diff.current_file();
let available_width = area.width as usize;
let diff_pending = matches!(
app.multi_diff.current_file_diff_status(),
DiffStatus::Deferred | DiffStatus::Computing
) || app.view_build_pending()
|| app.syntax_warmup_pending();
let stats_known = insertions > 0 || deletions > 0;
let mut right_spans = if matches!(app.view_mode, ViewMode::Blame) {
blame_age_legend_spans(app)
} else if diff_pending {
if stats_known {
vec![
Span::styled(
diff_spinner_frame(),
Style::default().fg(app.theme.text_muted),
),
Span::raw(" "),
Span::styled(
format!("+{}", insertions),
Style::default().fg(app.theme.success),
),
Span::raw(" "),
Span::styled(
format!("-{}", deletions),
Style::default().fg(app.theme.error),
),
Span::raw(" "),
]
} else {
vec![
Span::styled(
diff_spinner_frame(),
Style::default().fg(app.theme.text_muted),
),
Span::raw(" "),
]
}
} else {
vec![
Span::styled(
format!("+{}", insertions),
Style::default().fg(app.theme.success),
),
Span::raw(" "),
Span::styled(
format!("-{}", deletions),
Style::default().fg(app.theme.error),
),
Span::raw(" "),
]
};
let right_width = spans_width(&right_spans);
let left_max = available_width.saturating_sub(right_width + 2);
let file_changed = app.file_changed_on_disk(app.multi_diff.selected_index);
let changed_marker_len = if file_changed { 2 } else { 0 };
let (name_text, status_style) = if let Some(file) = file {
let file_name = file
.display_name
.rsplit('/')
.next()
.unwrap_or(&file.display_name);
let name =
truncate_filename_keep_ext(file_name, left_max.saturating_sub(3 + changed_marker_len));
let status_style = match file.status {
FileStatus::Added | FileStatus::Untracked => Style::default().fg(app.theme.success),
FileStatus::Deleted => Style::default().fg(app.theme.error),
FileStatus::Modified => Style::default().fg(app.theme.warning),
FileStatus::Renamed => Style::default().fg(app.theme.info),
};
(name, status_style)
} else {
(String::new(), Style::default().fg(app.theme.text_muted))
};
let mut left_spans = vec![
Span::raw(" "),
Span::styled("■", status_style),
Span::raw(" "),
Span::styled(name_text, Style::default().fg(app.theme.text)),
];
if file_changed {
left_spans.push(Span::raw(" "));
left_spans.push(Span::styled(
"*",
Style::default()
.fg(app.theme.warning)
.add_modifier(Modifier::BOLD),
));
}
left_spans = clamp_spans_to_width(&left_spans, left_max);
left_spans = pad_spans_left(left_spans, left_max);
right_spans = clamp_spans_to_width(&right_spans, right_width + 1);
let right_spans = pad_spans_right(right_spans, right_width + 1);
let mut spans = Vec::new();
spans.extend(left_spans);
spans.extend(right_spans);
let mut paragraph = Paragraph::new(Line::from(spans));
if let Some(bg) = app.theme.background {
paragraph = paragraph.style(Style::default().bg(bg));
}
frame.render_widget(paragraph, area);
}
fn blame_age_legend_spans(app: &App) -> Vec<Span<'static>> {
let blocks = 10usize;
let mut spans = Vec::with_capacity(blocks + 3);
spans.push(Span::styled(
"Older ",
Style::default().fg(app.theme.text_muted),
));
let base = app.theme.warning;
let steps = blocks.saturating_sub(1).max(1) as f32;
for idx in 0..blocks {
let t = idx as f32 / steps;
spans.push(Span::styled(
"▮",
Style::default().fg(color::ramp_color(base, t)),
));
}
spans.push(Span::styled(
" Newer",
Style::default().fg(app.theme.text_muted),
));
spans.push(Span::raw(" "));
spans
}
fn draw_content(frame: &mut Frame, app: &mut App, area: Rect, show_topbar: bool) {
let min_width_for_panel = FILE_PANEL_MIN_WIDTH + DIFF_VIEW_MIN_WIDTH;
app.file_panel_auto_hidden = app.is_multi_file()
&& app.file_panel_visible
&& area.width < min_width_for_panel
&& !app.file_panel_manually_set;
let show_panel = if app.file_panel_manually_set {
app.is_multi_file() && app.file_panel_visible
} else {
app.is_multi_file() && app.file_panel_visible && area.width >= min_width_for_panel
};
if show_panel {
let panel_width = app.clamp_file_panel_width(area.width);
app.file_panel_width = panel_width;
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(panel_width), Constraint::Min(0), ])
.split(area);
app.file_panel_rect = Some((chunks[0].x, chunks[0].y, chunks[0].width, chunks[0].height));
draw_file_list(frame, app, chunks[0]);
if show_topbar {
let diff_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(chunks[1]);
draw_top_bar(frame, app, diff_chunks[0]);
app.last_viewport_height = diff_chunks[1].height as usize;
app.diff_view_area = Some((
diff_chunks[1].x,
diff_chunks[1].y,
diff_chunks[1].width,
diff_chunks[1].height,
));
draw_diff_view(frame, app, diff_chunks[1]);
} else {
app.last_viewport_height = chunks[1].height as usize;
app.diff_view_area =
Some((chunks[1].x, chunks[1].y, chunks[1].width, chunks[1].height));
draw_diff_view(frame, app, chunks[1]);
}
} else {
app.file_list_area = None;
app.file_list_rows.clear();
app.file_filter_area = None;
app.file_panel_rect = None;
if show_topbar {
let diff_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area);
draw_top_bar(frame, app, diff_chunks[0]);
app.last_viewport_height = diff_chunks[1].height as usize;
app.diff_view_area = Some((
diff_chunks[1].x,
diff_chunks[1].y,
diff_chunks[1].width,
diff_chunks[1].height,
));
draw_diff_view(frame, app, diff_chunks[1]);
} else {
app.last_viewport_height = area.height as usize;
app.diff_view_area = Some((area.x, area.y, area.width, area.height));
draw_diff_view(frame, app, area);
}
}
}
fn draw_file_list(frame: &mut Frame, app: &mut App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(0), Constraint::Length(1), ])
.split(area);
let content_area = chunks[0];
let separator_area = chunks[1];
let border_fg = if app.file_list_focused {
app.theme.border_active
} else {
app.theme.border_subtle
};
let panel_bg = app.theme.background_panel.or(app.theme.background);
let mut separator_style = Style::default().fg(border_fg);
if let Some(bg) = app.theme.background {
separator_style = separator_style.bg(bg);
}
let separator_text = "▏\n".repeat(separator_area.height as usize);
let separator = Paragraph::new(separator_text).style(separator_style);
frame.render_widget(separator, separator_area);
let show_filter =
app.file_list_focused || app.file_filter_active || !app.file_filter.is_empty();
let panel_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(if show_filter {
vec![
Constraint::Length(5), Constraint::Min(0), Constraint::Length(3), ]
} else {
vec![
Constraint::Length(5), Constraint::Min(0), ]
})
.split(content_area);
let header_area = panel_chunks[0];
let list_area = panel_chunks[1];
let filter_area = if show_filter {
Some(panel_chunks[2])
} else {
None
};
let files = &app.multi_diff.files;
let file_count = app.multi_diff.file_count();
let mut added = 0usize;
let mut modified = 0usize;
let mut deleted = 0usize;
let mut renamed = 0usize;
for file in files {
match file.status {
FileStatus::Added | FileStatus::Untracked => added += 1,
FileStatus::Deleted => deleted += 1,
FileStatus::Modified => modified += 1,
FileStatus::Renamed => renamed += 1,
}
}
let via_text = if app.multi_diff.is_git_mode() {
"via git"
} else {
"via diff"
};
let root_path = app
.multi_diff
.repo_root()
.and_then(|p| {
p.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| ".".to_string());
let header_max_width = header_area.width.saturating_sub(1) as usize;
let range_display = app.multi_diff.git_range_display();
let header_text = if let Some((from, to)) = range_display {
let range_text = format!("{from}..{to}");
let range_width = text_width(&range_text);
if header_max_width <= range_width {
truncate_text(&range_text, header_max_width)
} else {
let sep = " • ";
let sep_width = text_width(sep);
let root_max_width = header_max_width.saturating_sub(range_width + sep_width + 2);
let root_display = truncate_path(&root_path, root_max_width);
if root_display.is_empty() {
truncate_text(&range_text, header_max_width)
} else {
format!("{root_display}{sep}{range_text}")
}
}
} else {
let root_label = "Root ";
let root_max_width = header_area
.width
.saturating_sub((root_label.len() + 1) as u16) as usize;
format!(
"{}{}",
root_label,
truncate_path(&root_path, root_max_width)
)
};
let header_lines = vec![
Line::raw(""),
Line::from(vec![
Span::raw(" "),
Span::styled(header_text, Style::default().fg(app.theme.text_muted)),
]),
Line::raw(""),
Line::from(vec![
Span::raw(" "),
Span::styled("●", Style::default().fg(app.theme.text_muted)),
Span::raw(" "),
Span::styled(
format!("{} files", file_count),
Style::default().fg(app.theme.text),
),
Span::raw(" "),
Span::styled(via_text, Style::default().fg(app.theme.text_muted)),
]),
Line::from(vec![
Span::raw(" "),
Span::styled(
format!("+{}", added),
Style::default().fg(app.theme.success),
),
Span::raw(" "),
Span::styled(
format!("~{}", modified),
Style::default().fg(app.theme.warning),
),
Span::raw(" "),
Span::styled(
format!("-{}", deleted),
Style::default().fg(app.theme.error),
),
Span::raw(" "),
Span::styled(format!("→{}", renamed), Style::default().fg(app.theme.info)),
]),
];
let mut header = Paragraph::new(header_lines);
if let Some(bg) = panel_bg {
header = header.style(Style::default().bg(bg));
}
frame.render_widget(header, header_area);
let filtered_indices = app.filtered_file_indices();
let mut items = Vec::new();
let mut row_map: Vec<Option<usize>> = Vec::new();
let mut remaining = list_area.height.saturating_sub(2) as usize;
let mut current_group: Option<String> = None;
let mut idx = app.file_list_scroll;
while idx < filtered_indices.len() && remaining > 0 {
let file_idx = filtered_indices[idx];
let file = &files[file_idx];
let group = match file.display_name.rsplit_once('/') {
Some((dir, _)) => dir.to_string(),
None => "Root Path".to_string(),
};
if current_group.as_deref() != Some(&group) {
if current_group.is_some() && remaining > 0 {
items.push(ListItem::new(Line::raw("")));
row_map.push(None);
remaining -= 1;
if remaining == 0 {
break;
}
}
let header_max = list_area.width.saturating_sub(6).max(1) as usize;
let header_text = truncate_path(&group, header_max);
let header_line = Line::from(vec![
Span::raw(" "),
Span::styled(
header_text,
Style::default()
.fg(app.theme.text_muted)
.add_modifier(Modifier::DIM),
),
]);
items.push(ListItem::new(header_line));
row_map.push(None);
current_group = Some(group);
remaining -= 1;
if remaining == 0 {
break;
}
}
let status_style = match file.status {
FileStatus::Added | FileStatus::Untracked => Style::default().fg(app.theme.success),
FileStatus::Deleted => Style::default().fg(app.theme.error),
FileStatus::Modified => Style::default().fg(app.theme.warning),
FileStatus::Renamed => Style::default().fg(app.theme.info),
};
let is_selected = file_idx == app.multi_diff.selected_index;
let selected_bg = if is_selected {
if app.file_list_focused {
app.theme.background_element.or(app.theme.background_panel)
} else {
app.theme.background_panel
}
} else {
None
};
let show_for_row = match app.file_count_mode {
crate::config::FileCountMode::Active => is_selected,
crate::config::FileCountMode::Focused => app.file_list_focused,
crate::config::FileCountMode::All => true,
crate::config::FileCountMode::Off => false,
};
let show_signs = show_for_row && (file.binary || file.insertions > 0 || file.deletions > 0);
let insert_text = if show_signs && !file.binary {
format!("+{}", file.insertions)
} else {
String::new()
};
let delete_text = if show_signs && !file.binary {
format!("-{}", file.deletions)
} else {
String::new()
};
let signs_len = if show_signs {
if file.binary {
1 + "bin".len()
} else {
1 + insert_text.len() + 1 + delete_text.len()
}
} else {
0
};
let file_changed = app.file_changed_on_disk(file_idx);
let changed_marker_len = if file_changed { 2 } else { 0 };
let file_name = file
.display_name
.rsplit('/')
.next()
.unwrap_or(&file.display_name);
let max_name_len = list_area
.width
.saturating_sub(8 + signs_len as u16 + changed_marker_len as u16)
.max(1) as usize;
let name = truncate_filename_keep_ext(file_name, max_name_len);
let mut icon_style = status_style;
if let Some(bg) = selected_bg {
icon_style = icon_style.bg(bg);
}
let mut name_style = Style::default().fg(app.theme.text);
if is_selected {
name_style = name_style.add_modifier(Modifier::BOLD);
}
if let Some(bg) = selected_bg {
name_style = name_style.bg(bg);
}
let marker_style = if is_selected {
Style::default()
.fg(app.theme.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(app.theme.text_muted)
};
let marker = if is_selected { "•" } else { " " };
let mut line_spans = vec![
Span::styled(marker, marker_style),
Span::raw(" "),
Span::styled("■", icon_style),
Span::raw(" "),
Span::styled(name, name_style),
];
if show_signs {
line_spans.push(Span::raw(" "));
let sign_style = if app.file_list_focused && is_selected {
Style::default().fg(app.theme.success)
} else {
Style::default().fg(app.theme.text_muted)
};
let delete_style = if app.file_list_focused && is_selected {
Style::default().fg(app.theme.error)
} else {
Style::default().fg(app.theme.text_muted)
};
if file.binary {
line_spans.push(Span::styled("bin", sign_style));
} else {
line_spans.push(Span::styled(insert_text, sign_style));
line_spans.push(Span::raw(" "));
line_spans.push(Span::styled(delete_text, delete_style));
}
}
if file_changed {
let mut changed_style = Style::default()
.fg(app.theme.warning)
.add_modifier(Modifier::BOLD);
if let Some(bg) = selected_bg {
changed_style = changed_style.bg(bg);
}
line_spans.push(Span::raw(" "));
line_spans.push(Span::styled("*", changed_style));
}
let line = Line::from(line_spans);
items.push(ListItem::new(line));
row_map.push(Some(file_idx));
remaining -= 1;
idx += 1;
}
let mut block = Block::default().padding(ratatui::widgets::Padding::new(1, 1, 1, 0));
if let Some(bg) = panel_bg {
block = block.style(Style::default().bg(bg));
}
let file_list = List::new(items).block(block);
app.file_list_area = Some((list_area.x, list_area.y, list_area.width, list_area.height));
app.file_list_rows = row_map;
frame.render_widget(file_list, list_area);
let has_query = !app.file_filter.is_empty();
let no_results = has_query && filtered_indices.is_empty();
if no_results {
let mut empty = Paragraph::new(Line::from(Span::styled(
"No Filter Results",
Style::default().fg(app.theme.text_muted),
)))
.alignment(Alignment::Center)
.block(Block::default().padding(ratatui::widgets::Padding::new(0, 0, 1, 0)));
if let Some(bg) = panel_bg {
empty = empty.style(Style::default().bg(bg));
}
frame.render_widget(empty, list_area);
}
if let Some(filter_area) = filter_area {
app.file_filter_area = Some((
filter_area.x,
filter_area.y,
filter_area.width,
filter_area.height,
));
let filter_bg = app
.theme
.background_element
.or(app.theme.background_panel)
.or(app.theme.background);
let filter_text = if app.file_filter_active {
if has_query {
format!("> {}", app.file_filter)
} else {
"> Filter file name".to_string()
}
} else if has_query {
app.file_filter.clone()
} else {
"\"/\" Filter".to_string()
};
let filter_style = if app.file_filter_active {
Style::default().fg(app.theme.text)
} else {
Style::default().fg(app.theme.text_muted)
};
let mut filter = Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled(filter_text, filter_style),
]))
.alignment(Alignment::Left);
let mut filter_block = Block::default().padding(ratatui::widgets::Padding::new(1, 1, 1, 0));
if let Some(bg) = filter_bg {
filter_block = filter_block.style(Style::default().bg(bg));
}
filter = filter.block(filter_block);
frame.render_widget(filter, filter_area);
} else {
app.file_filter_area = None;
}
}
fn draw_diff_view(frame: &mut Frame, app: &mut App, area: Rect) {
match app.view_mode {
ViewMode::UnifiedPane => render_unified_pane(frame, app, area),
ViewMode::Split => render_split(frame, app, area),
ViewMode::Evolution => render_evolution(frame, app, area),
ViewMode::Blame => render_blame(frame, app, area),
}
}
fn draw_review_comment_overlays(frame: &mut Frame, app: &mut App) {
app.clear_review_preview_boxes();
let Some((x, y, width, height)) = app.diff_view_area else {
return;
};
if width < 20 || height < 4 {
return;
}
let diff_area = Rect::new(x, y, width, height);
let overlays = app.review_comment_overlays_for_current_file();
if overlays.is_empty() {
return;
}
let scroll_offset = app.render_scroll_offset();
let diff_bottom = diff_area.y.saturating_add(diff_area.height);
if matches!(app.view_mode, ViewMode::UnifiedPane | ViewMode::Split) {
return;
}
let max_popup_width = diff_area.width.saturating_sub(8);
if max_popup_width < 16 {
return;
}
let popup_width = if max_popup_width < 24 {
max_popup_width
} else {
max_popup_width.min(40)
};
let mut next_free_y = diff_area.y;
for overlay in overlays.into_iter().take(16) {
if overlay.display_idx < scroll_offset {
continue;
}
let row = overlay.display_idx.saturating_sub(scroll_offset) as u16;
if row >= diff_area.height {
continue;
}
let anchor_y = diff_area.y.saturating_add(row);
let preferred_y = anchor_y.saturating_add(1);
let popup_height = 3u16;
let mut popup_y = preferred_y.max(next_free_y);
if popup_y.saturating_add(popup_height) > diff_bottom {
let fallback = diff_bottom.saturating_sub(popup_height);
if fallback <= anchor_y {
continue;
}
popup_y = fallback.max(next_free_y);
if popup_y.saturating_add(popup_height) > diff_bottom || popup_y <= anchor_y {
continue;
}
}
let popup_x = diff_area.x.saturating_add(
diff_area
.width
.saturating_sub(popup_width)
.saturating_sub(1),
);
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
frame.render_widget(Clear, popup_area);
let mut block = Block::default()
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(app.theme.border_subtle));
if let Some(bg) = app.theme.background_panel.or(app.theme.background) {
block = block.style(Style::default().bg(bg));
}
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
let preview_text = app.review_preview_hint_text(&overlay);
let preview = truncate_text(&preview_text, inner.width as usize);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
preview,
Style::default().fg(app.theme.text),
))),
inner,
);
app.add_review_preview_box(
popup_area.x,
popup_area.y,
popup_area.width,
popup_area.height,
overlay.anchor_key,
);
next_free_y = popup_y.saturating_add(popup_height);
}
}
fn draw_review_editor_overlay(frame: &mut Frame, app: &mut App) {
let Some(editor) = app.review_editor_render() else {
return;
};
let Some((x, y, width, height)) = app.diff_view_area else {
return;
};
if width < 20 || height < 4 {
return;
}
let diff_area = Rect::new(x, y, width, height);
let editor_area = if app.view_mode == ViewMode::Split {
let panes = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(diff_area);
if editor.prefer_right {
panes[1]
} else {
panes[0]
}
} else {
diff_area
};
let render_scroll = app.render_scroll_offset() as isize;
let max_row = editor_area.height.saturating_sub(1) as isize;
let anchor_span_rows = editor.anchor_display_span.and_then(|(start_idx, end_idx)| {
let start_rel = start_idx as isize - render_scroll;
let end_rel = end_idx as isize - render_scroll;
if end_rel < 0 || start_rel > max_row {
None
} else {
Some((
start_rel.clamp(0, max_row) as u16,
end_rel.clamp(0, max_row) as u16,
))
}
});
let anchor_row = anchor_span_rows
.map(|(start, _)| start)
.or_else(|| {
editor.display_idx_hint.map(|idx| {
idx.saturating_sub(app.render_scroll_offset())
.min(editor_area.height.saturating_sub(1) as usize) as u16
})
})
.unwrap_or(0)
.min(editor_area.height.saturating_sub(1));
let forbidden_rows = anchor_span_rows.unwrap_or((anchor_row, anchor_row));
let max_popup_width = editor_area.width.saturating_sub(2);
let popup_width = if max_popup_width < 24 {
max_popup_width
} else {
max_popup_width.min(72)
};
let desired_popup_height = (editor.lines.len() as u16).saturating_add(5).clamp(6, 12);
let min_popup_height = 4u16;
let mut popup_height =
desired_popup_height.min(editor_area.height.saturating_sub(1).max(min_popup_height));
let popup_x = editor_area.x.saturating_add(1);
let area_top = editor_area.y;
let area_bottom = editor_area.y.saturating_add(editor_area.height);
let forbidden_top_y = editor_area.y.saturating_add(forbidden_rows.0);
let forbidden_bottom_y = editor_area.y.saturating_add(forbidden_rows.1);
let placement_gap = 1u16;
let below_y_gap = forbidden_bottom_y
.saturating_add(1)
.saturating_add(placement_gap);
let below_space_gap = area_bottom.saturating_sub(below_y_gap);
let below_y_tight = forbidden_bottom_y.saturating_add(1);
let below_space_tight = area_bottom.saturating_sub(below_y_tight);
let above_space = forbidden_top_y.saturating_sub(area_top);
let above_space_gap = above_space.saturating_sub(placement_gap);
let popup_y = if below_space_gap >= min_popup_height {
popup_height = popup_height.min(below_space_gap);
below_y_gap
} else if below_space_tight >= min_popup_height {
popup_height = popup_height.min(below_space_tight);
below_y_tight
} else if above_space_gap >= min_popup_height {
popup_height = popup_height.min(above_space_gap);
forbidden_top_y
.saturating_sub(placement_gap)
.saturating_sub(popup_height)
} else if above_space >= min_popup_height {
popup_height = popup_height.min(above_space);
forbidden_top_y.saturating_sub(popup_height)
} else if editor.anchor_is_hunk {
popup_height = popup_height.min(editor_area.height.max(1));
let center_y =
forbidden_top_y.saturating_add(forbidden_bottom_y.saturating_sub(forbidden_top_y) / 2);
let max_y = area_bottom.saturating_sub(popup_height);
center_y
.saturating_sub(popup_height / 2)
.max(area_top)
.min(max_y)
} else {
popup_height = popup_height.min(editor_area.height.max(1));
let max_y = area_bottom.saturating_sub(popup_height);
let mut y = below_y_tight.min(max_y).max(area_top);
let overlaps_forbidden = y <= forbidden_bottom_y
&& y.saturating_add(popup_height).saturating_sub(1) >= forbidden_top_y;
if overlaps_forbidden && above_space > 0 {
popup_height = popup_height.min(above_space);
y = forbidden_top_y.saturating_sub(popup_height);
}
y
};
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
frame.render_widget(Clear, popup_area);
let mut block = Block::default()
.title(Span::styled(
editor.title,
Style::default()
.fg(app.theme.accent)
.add_modifier(Modifier::BOLD),
))
.title_bottom(Span::styled(
" Ctrl+Enter save • Esc cancel • @ mention ",
Style::default().fg(app.theme.text_muted),
))
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(app.theme.border_active));
if let Some(bg) = app.theme.background_panel.or(app.theme.background) {
block = block.style(Style::default().bg(bg));
}
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(inner);
let anchor_line = Paragraph::new(Line::from(vec![
Span::styled(" ", Style::default().fg(app.theme.text_muted)),
Span::styled(
editor.anchor_label,
Style::default().fg(app.theme.text_muted),
),
]));
frame.render_widget(anchor_line, chunks[0]);
let text_area = chunks[1];
let padded_text_area = text_area.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 0,
});
let text_area = if padded_text_area.width == 0 {
text_area
} else {
padded_text_area
};
let visible_lines = text_area.height.max(1) as usize;
let wrap_width = text_area.width.max(1) as usize;
let mut visual_lines: Vec<String> = Vec::new();
let mut cursor_visual_row = 0usize;
let mut cursor_visual_col = 0usize;
for (logical_row, line) in editor.lines.iter().enumerate() {
let wrapped = wrap_editor_line(line, wrap_width);
if logical_row < editor.cursor_row {
cursor_visual_row = cursor_visual_row.saturating_add(wrapped.len());
} else if logical_row == editor.cursor_row {
let (row_in_line, col_in_line) =
editor_cursor_visual(line, editor.cursor_col, wrap_width);
cursor_visual_row = cursor_visual_row.saturating_add(row_in_line);
cursor_visual_col = col_in_line;
}
visual_lines.extend(wrapped);
}
if visual_lines.is_empty() {
visual_lines.push(String::new());
}
let max_start = visual_lines.len().saturating_sub(visible_lines);
let start_row = cursor_visual_row
.saturating_add(1)
.saturating_sub(visible_lines)
.min(max_start);
let end_row = (start_row + visible_lines).min(visual_lines.len());
let text_lines: Vec<Line> = visual_lines[start_row..end_row]
.iter()
.map(|line| {
Line::from(Span::styled(
if line.is_empty() {
" ".to_string()
} else {
line.clone()
},
Style::default().fg(app.theme.text),
))
})
.collect();
frame.render_widget(Paragraph::new(text_lines), text_area);
let cursor_screen_row = cursor_visual_row.saturating_sub(start_row);
let cursor_x = text_area.x.saturating_add(cursor_visual_col as u16).min(
text_area
.x
.saturating_add(text_area.width.saturating_sub(1)),
);
let cursor_y = text_area.y.saturating_add(cursor_screen_row as u16).min(
text_area
.y
.saturating_add(text_area.height.saturating_sub(1)),
);
if let Some(mentions) = app.review_mention_render() {
let max_items = mentions.items.len().min(5);
if max_items > 0 && diff_area.width > 14 && diff_area.height > 4 {
let start_idx = mentions
.scroll_start
.min(mentions.items.len().saturating_sub(max_items));
let end_idx = (start_idx + max_items).min(mentions.items.len());
let max_text_width = mentions.items[start_idx..end_idx]
.iter()
.map(|item| text_width(item))
.max()
.unwrap_or(0)
.min(diff_area.width.saturating_sub(10) as usize);
let popup_width = (max_text_width as u16)
.saturating_add(4)
.max(12)
.min(diff_area.width.saturating_sub(2).max(1));
let popup_height = (max_items as u16)
.saturating_add(2)
.min(diff_area.height.saturating_sub(2).max(3));
let min_x = diff_area.x.saturating_add(1);
let max_x = diff_area
.x
.saturating_add(diff_area.width)
.saturating_sub(popup_width)
.saturating_sub(1);
let min_y = diff_area.y.saturating_add(1);
let max_y = diff_area
.y
.saturating_add(diff_area.height)
.saturating_sub(popup_height)
.saturating_sub(1);
let mention_area = if min_x > max_x || min_y > max_y {
Rect::new(min_x, min_y, popup_width, popup_height)
} else {
let clamp_u16 = |value: u16, lo: u16, hi: u16| value.max(lo).min(hi);
let right_x = cursor_x.saturating_add(1);
let left_x = cursor_x.saturating_sub(popup_width.saturating_add(1));
let centered_x = cursor_x.saturating_sub(popup_width / 2);
let below_y = cursor_y;
let above_y = cursor_y.saturating_sub(popup_height);
let candidates = [
(right_x, below_y),
(right_x, above_y),
(left_x, below_y),
(left_x, above_y),
(centered_x, below_y),
(centered_x, above_y),
(right_x, cursor_y),
(left_x, cursor_y),
(centered_x, cursor_y),
(min_x, below_y),
(max_x, below_y),
(min_x, above_y),
(max_x, above_y),
(min_x, min_y),
];
let mut fallback = Rect::new(
clamp_u16(right_x, min_x, max_x),
clamp_u16(below_y, min_y, max_y),
popup_width,
popup_height,
);
for (cx, cy) in candidates {
let x = clamp_u16(cx, min_x, max_x);
let y = clamp_u16(cy, min_y, max_y);
let rect = Rect::new(x, y, popup_width, popup_height);
let contains_cursor = cursor_x >= rect.x
&& cursor_x < rect.x.saturating_add(rect.width)
&& cursor_y >= rect.y
&& cursor_y < rect.y.saturating_add(rect.height);
if !contains_cursor {
fallback = rect;
break;
}
}
fallback
};
frame.render_widget(Clear, mention_area);
let mut mention_block = Block::default()
.title(Span::styled(
format!(" @{} ", mentions.query),
Style::default().fg(app.theme.text_muted),
))
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(app.theme.border_subtle));
if let Some(bg) = app.theme.background_panel.or(app.theme.background) {
mention_block = mention_block.style(Style::default().bg(bg));
}
let inner = mention_block.inner(mention_area);
frame.render_widget(mention_block, mention_area);
let mut mention_lines: Vec<Line> = Vec::new();
for (local_idx, item) in mentions.items[start_idx..end_idx].iter().enumerate() {
let idx = start_idx + local_idx;
let text = truncate_text(item, inner.width.saturating_sub(2) as usize);
let style = if idx == mentions.selected {
Style::default().fg(app.theme.accent)
} else {
Style::default().fg(app.theme.text)
};
mention_lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(text, style),
Span::raw(" "),
]));
}
frame.render_widget(Paragraph::new(mention_lines), inner);
}
}
frame.set_cursor_position((cursor_x, cursor_y));
}
fn draw_zen_progress(frame: &mut Frame, app: &mut App) {
let state = app.state();
let label = format!(" {}/{} ", state.current_step + 1, state.total_steps);
let area = frame.area();
let width = label.len() as u16;
let x = area.width.saturating_sub(width + 1);
let y = area.height.saturating_sub(1);
let progress_area = Rect::new(x, y, width, 1);
let text = Paragraph::new(label).style(Style::default().fg(app.theme.text));
frame.render_widget(text, progress_area);
}
fn draw_help_popover(frame: &mut Frame, app: &mut App) {
let area = frame.area();
let popup_width = 61u16.min(area.width.saturating_sub(4));
let key_style = Style::default().fg(app.theme.accent);
let label_style = Style::default().fg(app.theme.text);
let dim_style = Style::default().fg(app.theme.text_muted);
let section_style = Style::default().fg(app.theme.primary);
let mut help_keys = vec![
"j / k / ↑↓",
"h / l / ←→",
"b / e",
"p / P",
"y / Y",
"/",
"n / N",
"c / C",
"m / M",
"x / X",
"Ctrl+x",
":<line>",
":h<num>",
":s<num>",
"< / >",
"gg / G",
"J / K",
"H / L",
"0 / $",
"^U / ^D",
"^G",
"o / Ctrl+e",
"z",
"w",
"t",
"s",
"S",
"Space / B",
"+ / -",
"a",
"Tab",
"Z",
"r",
"Ctrl+P",
"Ctrl+Shift+P",
];
if app.is_multi_file() {
help_keys.extend_from_slice(&["[ / ]", "f", "Enter", "j / k / ↑↓", "/", "r"]);
}
let content_width = popup_width.saturating_sub(2) as usize;
let max_key_width = help_keys
.iter()
.map(|key| text_width(key))
.max()
.unwrap_or(0);
let min_desc_width = 16usize;
let max_key_pad = max_key_width.saturating_add(2).min(12);
let key_pad = max_key_pad.min(content_width.saturating_sub(min_desc_width).max(2));
let key_field_width = key_pad.saturating_sub(2);
let desc_width = content_width.saturating_sub(key_pad).max(1);
let indent = " ".repeat(key_pad + 5);
let wrap_text = |text: &str| -> Vec<String> {
if desc_width == 0 {
return vec![String::new()];
}
let mut lines = Vec::new();
let mut current = String::new();
let mut current_width = 0usize;
let push_chunk = |lines: &mut Vec<String>, chunk: &str| {
if !chunk.is_empty() {
lines.push(chunk.to_string());
}
};
let push_word = |lines: &mut Vec<String>, word: &str| {
let word_width = text_width(word);
if word_width <= desc_width {
push_chunk(lines, word);
return;
}
let mut chunk = String::new();
let mut chunk_width = 0usize;
for ch in word.chars() {
let ch_width = text_width(&ch.to_string());
if chunk_width + ch_width > desc_width && !chunk.is_empty() {
lines.push(chunk.clone());
chunk.clear();
chunk_width = 0;
}
if ch_width <= desc_width {
chunk.push(ch);
chunk_width += ch_width;
}
}
if !chunk.is_empty() {
lines.push(chunk);
}
};
for word in text.split_whitespace() {
let word_width = text_width(word);
if current.is_empty() {
if word_width <= desc_width {
current.push_str(word);
current_width = word_width;
} else {
push_word(&mut lines, word);
}
continue;
}
if current_width + 1 + word_width <= desc_width {
current.push(' ');
current.push_str(word);
current_width += 1 + word_width;
} else {
lines.push(current);
current = String::new();
current_width = 0;
if word_width <= desc_width {
current.push_str(word);
current_width = word_width;
} else {
push_word(&mut lines, word);
}
}
}
if !current.is_empty() {
lines.push(current);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
};
let truncate_key = |key: &str| -> String {
if key_field_width == 0 {
return String::new();
}
let mut out = String::new();
let mut width = 0usize;
for ch in key.chars() {
let ch_width = text_width(&ch.to_string());
if width + ch_width > key_field_width {
break;
}
out.push(ch);
width += ch_width;
}
out
};
let push_help_line = |lines: &mut Vec<Line>, key: &str, desc: &str| {
let key_text = format!(
" {:<width$} ",
truncate_key(key),
width = key_field_width
);
let wrapped = wrap_text(desc);
for (idx, line) in wrapped.into_iter().enumerate() {
let left = if idx == 0 {
key_text.clone()
} else {
indent.clone()
};
lines.push(Line::from(vec![
Span::styled(left, key_style),
Span::styled(line, label_style),
]));
}
};
let mut lines = vec![Line::from(Span::styled(" Navigation", section_style))];
push_help_line(&mut lines, "j / k / ↑↓", "Step forward/back");
push_help_line(&mut lines, "h / l / ←→", "Prev/next hunk");
push_help_line(&mut lines, "b / e", "Hunk begin/end");
push_help_line(&mut lines, "g b", "Blame (step)");
push_help_line(&mut lines, "p", "Peek change");
push_help_line(&mut lines, "P", "Peek old hunk");
push_help_line(&mut lines, "y / Y", "Yank line/hunk");
push_help_line(&mut lines, "g y / g Y", "Copy patch (line/hunk)");
push_help_line(&mut lines, "/", "Search (diff pane)");
push_help_line(&mut lines, "n / N", "Next/prev match");
push_help_line(&mut lines, "c / C", "Next/prev conflict");
push_help_line(&mut lines, "m / M", "Add/update line/hunk comment");
push_help_line(&mut lines, "x / X", "Remove line/hunk comment");
push_help_line(&mut lines, "Ctrl+x", "Clear all comments");
push_help_line(&mut lines, ":<line>", "Go to line");
push_help_line(&mut lines, ":h<num>", "Go to hunk");
push_help_line(&mut lines, ":s<num>", "Go to step");
push_help_line(&mut lines, "< / >", "First/last step (or hunk in no-step)");
push_help_line(&mut lines, "gg / G", "Go to start/end");
push_help_line(&mut lines, "J / K", "Scroll up/down");
push_help_line(&mut lines, "H / L", "Scroll left/right");
push_help_line(&mut lines, "0 / $", "Scroll to line start/end");
push_help_line(&mut lines, "^U / ^D", "Scroll half-page");
push_help_line(&mut lines, "^G", "Show full file path");
push_help_line(&mut lines, "o / Ctrl+e", "Open file in editor");
push_help_line(&mut lines, "z", "Center on active");
push_help_line(&mut lines, "w", "Toggle line wrap");
push_help_line(&mut lines, "f", "Toggle context folding");
push_help_line(&mut lines, "t", "Toggle syntax highlight");
if app.view_mode == ViewMode::Evolution {
push_help_line(&mut lines, "E", "Toggle evo syntax (context/full)");
}
push_help_line(&mut lines, "s", "Toggle stepping");
push_help_line(&mut lines, "S", "Toggle strikethrough");
push_help_line(&mut lines, "Ctrl+P", "Command palette");
push_help_line(&mut lines, "Ctrl+Shift+P", "Quick file search");
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(" Playback", section_style)));
push_help_line(&mut lines, "Space / B", "Autoplay forward/reverse");
push_help_line(&mut lines, "r", "Replay last step");
push_help_line(&mut lines, "nr", "Replay last n steps");
push_help_line(
&mut lines,
"+ / -",
&format!("Speed ({}ms)", app.animation_speed),
);
push_help_line(&mut lines, "a", "Toggle animation");
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(" View", section_style)));
push_help_line(&mut lines, "Tab", "Cycle view mode");
push_help_line(&mut lines, "Shift-Tab", "Cycle view mode (reverse)");
push_help_line(&mut lines, "Z", "Zen mode");
push_help_line(&mut lines, "R", "Refresh all files");
if app.is_multi_file() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(" Files", section_style)));
push_help_line(&mut lines, "[ / ]", "Prev/next file");
push_help_line(&mut lines, "Ctrl+F", "Toggle file panel");
push_help_line(&mut lines, "Enter", "Focus file list");
push_help_line(&mut lines, "j / k / ↑↓", "Move selection (focused)");
push_help_line(&mut lines, "/", "Filter files (when focused)");
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(format!(" {:<12}", "?"), key_style),
Span::styled("Close help", dim_style),
]));
let quit_label = "Quit (prints comments if any)";
lines.push(Line::from(vec![
Span::styled(format!(" {:<12}", "q / Esc"), key_style),
Span::styled(quit_label, label_style),
]));
let base_height = if app.is_multi_file() { 31 } else { 26 };
let min_height = (base_height as u16).min(area.height.saturating_sub(4));
let needed_height = (lines.len() as u16).saturating_add(2);
let popup_height = needed_height
.max(min_height)
.min(area.height.saturating_sub(4));
let popup_x = (area.width.saturating_sub(popup_width)) / 2;
let popup_y = (area.height.saturating_sub(popup_height)) / 2;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
frame.render_widget(Clear, popup_area);
let mut block = Block::default()
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(app.theme.border_active));
block = block.border_style(Style::default().fg(app.theme.border_active));
if let Some(bg) = app.theme.background {
block = block.style(Style::default().bg(bg));
}
let inner_height = popup_height.saturating_sub(2) as usize;
let max_scroll = lines.len().saturating_sub(inner_height);
app.help_max_scroll = max_scroll;
let scroll = app.help_scroll.min(max_scroll) as u16;
let total_lines = max_scroll + inner_height;
let help_block = Paragraph::new(lines)
.block(block)
.alignment(Alignment::Left)
.scroll((scroll, 0));
frame.render_widget(help_block, popup_area);
if max_scroll > 0 {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓"));
let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll as usize);
frame.render_stateful_widget(
scrollbar,
popup_area.inner(ratatui::layout::Margin {
vertical: 1,
horizontal: 0,
}),
&mut scrollbar_state,
);
}
}
fn draw_path_popup(frame: &mut Frame, app: &App) {
let area = frame.area();
let file_path = app.current_file_path();
let popup_width = (file_path.len() as u16 + 6).min(area.width.saturating_sub(4));
let popup_height = 3u16;
let popup_x = (area.width.saturating_sub(popup_width)) / 2;
let popup_y = (area.height.saturating_sub(popup_height)) / 2;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
frame.render_widget(Clear, popup_area);
let max_path_len = (popup_width.saturating_sub(4)) as usize;
let display_path = if file_path.len() > max_path_len {
format!(
"…{}",
&file_path[file_path.len().saturating_sub(max_path_len - 1)..]
)
} else {
file_path
};
let mut block = Block::default()
.borders(Borders::ALL)
.title(" File Path ")
.title_alignment(Alignment::Center)
.border_style(Style::default().fg(app.theme.border_active));
block = block.border_style(Style::default().fg(app.theme.border_active));
if let Some(bg) = app.theme.background {
block = block.style(Style::default().bg(bg));
}
let path_block = Paragraph::new(display_path)
.block(block)
.style(Style::default().fg(app.theme.text))
.alignment(Alignment::Center);
frame.render_widget(path_block, popup_area);
}
fn draw_command_palette_popover(frame: &mut Frame, app: &mut App) {
let area = frame.area();
let popup_width = 56u16.min(area.width.saturating_sub(4));
let max_height = (area.height / 2).saturating_sub(2).max(6);
let entries = app.command_palette_filtered_entries();
let selection = app.command_palette_selection();
let item_height = 1u16;
let overhead = 6u16;
let max_list_height = max_height.saturating_sub(overhead).max(1) as usize;
let list_height = entries.len().max(1).min(max_list_height);
let popup_height = (list_height as u16)
.saturating_add(overhead)
.min(max_height);
let popup_x = (area.width.saturating_sub(popup_width)) / 2;
let desired_y = area.height / 4;
let max_y = area.height.saturating_sub(popup_height);
let popup_y = desired_y.min(max_y);
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
frame.render_widget(Clear, popup_area);
let mut block = Block::default()
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded);
block = block.border_style(Style::default().fg(app.theme.border_active));
if let Some(bg) = app.theme.background {
block = block.style(Style::default().bg(bg));
}
frame.render_widget(block.clone(), popup_area);
let inner = block.inner(popup_area);
let padded = inner.inner(Margin {
vertical: 1,
horizontal: 1,
});
let content = if padded.width > 0 && padded.height > 0 {
padded
} else {
inner
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(1)])
.split(content);
let query = app.command_palette_query();
let placeholder = "Search for commands…";
let (query_text, query_style) = if query.is_empty() {
(placeholder, Style::default().fg(app.theme.text_muted))
} else {
(query, Style::default().fg(app.theme.text))
};
let input_line = Line::from(vec![
Span::styled("› ", Style::default().fg(app.theme.primary)),
Span::styled(query_text, query_style),
]);
frame.render_widget(
Paragraph::new(vec![input_line]).alignment(Alignment::Left),
chunks[0],
);
if entries.is_empty() {
app.set_command_palette_list_area(None, 0, 0, 1);
let line = Line::from(Span::styled(
"No results",
Style::default().fg(app.theme.text_muted),
));
frame.render_widget(
Paragraph::new(vec![line]).alignment(Alignment::Center),
chunks[1],
);
return;
}
let mut start = 0usize;
if selection >= list_height {
start = selection + 1 - list_height;
}
let end = (start + list_height).min(entries.len());
let visible = &entries[start..end];
let list_width = chunks[1].width.saturating_sub(2) as usize;
app.set_command_palette_list_area(
Some((chunks[1].x, chunks[1].y, chunks[1].width, chunks[1].height)),
start,
visible.len(),
item_height,
);
let items: Vec<ListItem> = visible
.iter()
.map(|entry| {
let label = truncate_text(&entry.label, list_width);
ListItem::new(Line::from(Span::styled(
label,
Style::default().fg(app.theme.text),
)))
})
.collect();
let mut state = ListState::default();
let selection_in_view = selection.saturating_sub(start);
state.select(Some(selection_in_view.min(visible.len().saturating_sub(1))));
let mut highlight_style = Style::default().fg(app.theme.accent);
if let Some(bg) = app.theme.background_element.or(app.theme.background_panel) {
highlight_style = highlight_style.bg(bg);
}
let list = List::new(items).highlight_style(highlight_style);
frame.render_stateful_widget(list, chunks[1], &mut state);
}
fn draw_file_search_popover(frame: &mut Frame, app: &mut App) {
let area = frame.area();
let popup_width = 60u16.min(area.width.saturating_sub(4));
let max_height = (area.height / 2).saturating_sub(2).max(6);
let indices = app.file_search_filtered_indices();
let selection = app.file_search_selection();
let item_height = 1u16;
let overhead = 6u16;
let max_list_height = max_height.saturating_sub(overhead).max(1) as usize;
let list_height = indices.len().max(1).min(max_list_height);
let popup_height = (list_height as u16)
.saturating_add(overhead)
.min(max_height);
let popup_x = (area.width.saturating_sub(popup_width)) / 2;
let desired_y = area.height / 4;
let max_y = area.height.saturating_sub(popup_height);
let popup_y = desired_y.min(max_y);
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
frame.render_widget(Clear, popup_area);
let mut block = Block::default()
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded);
block = block.border_style(Style::default().fg(app.theme.border_active));
if let Some(bg) = app.theme.background {
block = block.style(Style::default().bg(bg));
}
frame.render_widget(block.clone(), popup_area);
let inner = block.inner(popup_area);
let padded = inner.inner(Margin {
vertical: 1,
horizontal: 1,
});
let content = if padded.width > 0 && padded.height > 0 {
padded
} else {
inner
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(1)])
.split(content);
let query = app.file_search_query();
let placeholder = "Search for files…";
let (query_text, query_style) = if query.is_empty() {
(placeholder, Style::default().fg(app.theme.text_muted))
} else {
(query, Style::default().fg(app.theme.text))
};
let input_line = Line::from(vec![
Span::styled("› ", Style::default().fg(app.theme.primary)),
Span::styled(query_text, query_style),
]);
frame.render_widget(
Paragraph::new(vec![input_line]).alignment(Alignment::Left),
chunks[0],
);
if indices.is_empty() {
app.set_file_search_list_area(None, 0, 0, 1);
let line = Line::from(Span::styled(
"No results",
Style::default().fg(app.theme.text_muted),
));
frame.render_widget(
Paragraph::new(vec![line]).alignment(Alignment::Center),
chunks[1],
);
return;
}
let mut start = 0usize;
if selection >= list_height {
start = selection + 1 - list_height;
}
let end = (start + list_height).min(indices.len());
let visible = &indices[start..end];
let list_width = chunks[1].width.saturating_sub(2) as usize;
app.set_file_search_list_area(
Some((chunks[1].x, chunks[1].y, chunks[1].width, chunks[1].height)),
start,
visible.len(),
item_height,
);
let items: Vec<ListItem> = visible
.iter()
.map(|idx| {
let name = app.multi_diff.files[*idx].display_name.clone();
let label = truncate_path(&name, list_width);
ListItem::new(Line::from(Span::styled(
label,
Style::default().fg(app.theme.text),
)))
})
.collect();
let mut state = ListState::default();
let selection_in_view = selection.saturating_sub(start);
state.select(Some(selection_in_view.min(visible.len().saturating_sub(1))));
let mut highlight_style = Style::default().fg(app.theme.accent);
if let Some(bg) = app.theme.background_element.or(app.theme.background_panel) {
highlight_style = highlight_style.bg(bg);
}
let list = List::new(items).highlight_style(highlight_style);
frame.render_stateful_widget(list, chunks[1], &mut state);
}