use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect, Spacing};
use ratatui::style::{Modifier, Style};
use ratatui::symbols::merge::MergeStrategy;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use unicode_width::UnicodeWidthStr;
use crate::diff::{FileDiff, LineKind, build_split_rows};
use crate::state::{App, Focus, Mode};
use crate::theme::{Theme, mix_color};
pub fn render(f: &mut Frame, app: &mut App) {
let theme = Theme::for_name_with_overrides(app.theme_name, &app.color_overrides);
let footer_height = if app.show_hints { 2 } else { 1 };
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(footer_height),
])
.split(f.area());
crate::components::header::render(f, app, outer[0], &theme);
render_body(f, app, outer[1], &theme);
crate::components::footer::render(f, app, outer[2], &theme);
if app.show_comments_list {
crate::components::comments_list::render(f, app, &theme);
}
if app.show_revision_selector {
crate::components::revision_selector::render(f, app, &theme);
}
if app.show_help {
crate::components::help_overlay::render(f, &theme);
}
if app.preview_mode {
render_comments_preview_modal(f, app, &theme);
}
if app.mode == Mode::Insert && app.comment_modal {
crate::components::comment_composer::render(f, app, &theme);
}
}
fn render_body(f: &mut Frame, app: &mut App, area: Rect, theme: &Theme) {
if area.height < 3 || area.width < 4 {
app.file_list_area = Rect::default();
app.diff_area = Rect::default();
return;
}
if !theme.chrome.show_borders() {
render_body_sparse(f, app, area, theme);
return;
}
let [subheader_band, content_band] =
Layout::vertical([Constraint::Length(2), Constraint::Min(1)])
.spacing(Spacing::Overlap(1))
.areas::<2>(area);
let border_fg = theme.border_unfocused;
let make_block = |borders: Borders, bg| {
Block::new()
.borders(borders)
.merge_borders(MergeStrategy::Exact)
.border_style(Style::default().fg(border_fg).bg(bg))
.style(Style::default().bg(bg))
};
let sh_borders = Borders::LEFT | Borders::RIGHT | Borders::BOTTOM;
let body_borders = Borders::LEFT | Borders::RIGHT | Borders::TOP;
if app.show_file_list {
let pane_w = app.file_list_width + 1;
let h_layout = Layout::horizontal([Constraint::Length(pane_w), Constraint::Min(10)])
.spacing(Spacing::Overlap(1));
let [files_sh_area, diff_sh_area] = h_layout.areas::<2>(subheader_band);
let [files_body_area, diff_body_area] = h_layout.areas::<2>(content_band);
let files_sh_block = make_block(sh_borders, theme.bg);
let diff_sh_block = make_block(sh_borders, theme.bg);
let files_body_block = make_block(body_borders, theme.bg);
let diff_body_block = make_block(body_borders, theme.bg);
let files_sh_inner = files_sh_block.inner(files_sh_area);
let diff_sh_inner = diff_sh_block.inner(diff_sh_area);
let files_body_inner = files_body_block.inner(files_body_area);
let diff_body_inner = diff_body_block.inner(diff_body_area);
f.render_widget(files_sh_block, files_sh_area);
f.render_widget(diff_sh_block, diff_sh_area);
f.render_widget(files_body_block, files_body_area);
f.render_widget(diff_body_block, diff_body_area);
app.file_list_area = files_body_inner;
app.diff_area = diff_body_inner;
let focus = app.focus;
render_file_list(f, app, files_sh_inner, files_body_inner, theme);
render_diff(f, app, diff_sh_inner, diff_body_inner, theme);
let unfocused = match focus {
Focus::Files => diff_body_inner,
Focus::Diff => files_body_inner,
};
f.buffer_mut()
.set_style(unfocused, Style::default().add_modifier(Modifier::DIM));
} else {
let diff_sh_block = make_block(sh_borders, theme.bg);
let diff_body_block = make_block(body_borders, theme.bg);
let diff_sh_inner = diff_sh_block.inner(subheader_band);
let diff_body_inner = diff_body_block.inner(content_band);
f.render_widget(diff_sh_block, subheader_band);
f.render_widget(diff_body_block, content_band);
app.file_list_area = Rect::default();
app.diff_area = diff_body_inner;
render_diff(f, app, diff_sh_inner, diff_body_inner, theme);
}
}
fn render_body_sparse(f: &mut Frame, app: &mut App, area: Rect, theme: &Theme) {
let sh_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
};
let divider_area = Rect {
x: area.x,
y: area.y + 1,
width: area.width,
height: 1,
};
let content_area = Rect {
x: area.x,
y: area.y + 2,
width: area.width,
height: area.height - 2,
};
let rule_style = Style::default().fg(theme.border_unfocused).bg(theme.bg);
let rule = Line::from(Span::styled("─".repeat(area.width as usize), rule_style))
.style(Style::default().bg(theme.bg));
f.render_widget(
Paragraph::new(rule).style(Style::default().bg(theme.bg)),
divider_area,
);
if app.show_file_list {
let h_layout =
Layout::horizontal([Constraint::Length(app.file_list_width), Constraint::Min(10)]);
let [files_sh, diff_sh] = h_layout.areas::<2>(sh_area);
let [files_body, diff_body] = h_layout.areas::<2>(content_area);
app.file_list_area = files_body;
app.diff_area = diff_body;
render_file_list(f, app, files_sh, files_body, theme);
render_diff(f, app, diff_sh, diff_body, theme);
} else {
app.file_list_area = Rect::default();
app.diff_area = content_area;
render_diff(f, app, sh_area, content_area, theme);
}
}
fn render_file_list(
f: &mut Frame,
app: &mut App,
subheader_inner: Rect,
body_inner: Rect,
theme: &Theme,
) {
let tree = build_file_tree(
&app.files,
&app.comments,
&app.expanded_dirs,
app.no_emoji,
app.file_filter.as_deref(),
);
fn collect_items<'a>(entries: &'a [FileTreeEntry], out: &mut Vec<&'a FileTreeEntry>) {
for entry in entries {
out.push(entry);
if let FileTreeEntry::Dir { children, .. } = entry {
collect_items(children, out);
}
}
}
let mut flat_items: Vec<&FileTreeEntry> = Vec::new();
collect_items(&tree, &mut flat_items);
let selected_file_row = flat_items.iter().position(|e| {
matches!(
e,
FileTreeEntry::FileInfo { file_idx, .. } if *file_idx == app.selected
)
});
let items: Vec<ListItem> = flat_items
.iter()
.map(|entry| match entry {
FileTreeEntry::Dir { label, depth, .. } => {
let indent = " ".repeat(*depth);
ListItem::new(Line::from(vec![
Span::raw(indent),
Span::styled(
label.clone(),
Style::default()
.fg(theme.dir_fg)
.add_modifier(Modifier::BOLD),
),
]))
}
FileTreeEntry::FileInfo {
file_idx,
display,
depth,
badge,
status,
counts,
comment_badge,
viewed,
} => {
let is_selected = *file_idx == app.selected;
let badge_style = match status {
crate::diff::FileStatus::Added => Style::default().fg(theme.status_added),
crate::diff::FileStatus::Modified => Style::default().fg(theme.status_modified),
crate::diff::FileStatus::Deleted => Style::default().fg(theme.status_deleted),
crate::diff::FileStatus::Renamed => Style::default().fg(theme.status_renamed),
};
let dim = if *viewed && !is_selected {
Modifier::DIM
} else {
Modifier::empty()
};
let viewed_mark = if *viewed && !is_selected {
"\u{2713}"
} else {
""
};
let indent = " ".repeat(*depth);
let line = Line::from(vec![
Span::raw(indent),
Span::styled(badge.clone(), badge_style.add_modifier(dim)),
Span::styled(
display.clone(),
Style::default().fg(theme.context_fg).add_modifier(dim),
),
Span::styled(
counts.clone(),
Style::default().fg(theme.dim_fg).add_modifier(dim),
),
Span::styled(
comment_badge.clone(),
Style::default()
.fg(theme.shuire)
.add_modifier(Modifier::BOLD),
),
Span::styled(
viewed_mark.to_string(),
Style::default().fg(theme.status_added),
),
]);
ListItem::new(line)
}
})
.collect();
let highlight_idx = if flat_items.is_empty() {
None
} else if app.focus == Focus::Files {
Some(app.file_tree_cursor.min(flat_items.len() - 1))
} else {
selected_file_row
};
if body_inner.height == 0 {
return;
}
let title_fg = if app.focus == Focus::Files {
theme.shuire
} else {
theme.dim_fg
};
let count_str = format!("({}) ", app.files.len());
let mut header_spans = vec![
Span::styled(
" FILES ",
Style::default()
.fg(title_fg)
.bg(theme.bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(count_str, Style::default().fg(theme.dim_fg).bg(theme.bg)),
];
if app.mode == Mode::FileFilter {
header_spans.push(Span::styled(
format!(" /{}", app.input),
Style::default()
.fg(theme.shuire)
.bg(theme.bg)
.add_modifier(Modifier::BOLD),
));
header_spans.push(Span::styled(
"▏",
Style::default().fg(theme.caret_fg).bg(theme.bg),
));
} else if let Some(ref q) = app.file_filter {
header_spans.push(Span::styled(
format!(" /{}", q),
Style::default().fg(theme.context_fg).bg(theme.bg),
));
}
let header_used: usize = header_spans.iter().map(|s| s.content.width()).sum();
let sh_w = subheader_inner.width as usize;
if sh_w > header_used {
header_spans.push(Span::styled(
" ".repeat(sh_w - header_used),
Style::default().bg(theme.bg),
));
}
let header_line = Line::from(header_spans).style(Style::default().bg(theme.bg));
f.render_widget(Paragraph::new(header_line), subheader_inner);
let items: Vec<ListItem> = items
.into_iter()
.map(|it| it.style(Style::default().bg(theme.bg)))
.collect();
let list = List::new(items)
.style(Style::default().bg(theme.bg))
.highlight_style(Style::default().bg(theme.selection));
let mut state = ListState::default();
state.select(highlight_idx);
f.render_stateful_widget(list, body_inner, &mut state);
}
enum FileTreeEntry {
Dir {
path: String,
label: String,
depth: usize,
children: Vec<FileTreeEntry>,
},
FileInfo {
file_idx: usize,
display: String,
depth: usize,
badge: String,
status: crate::diff::FileStatus,
counts: String,
comment_badge: String,
viewed: bool,
},
}
fn get_file_icon(filename: &str) -> &'static str {
let ext = filename.rsplit('.').next().unwrap_or("");
match ext {
"rs" => "🦀",
"js" | "ts" | "jsx" | "tsx" | "mjs" | "cjs" => "⚡",
"py" => "🐍",
"go" => "🐹",
"java" => "☕",
"rb" => "💎",
"toml" => "🔧",
"yaml" | "yml" => "🔗",
"json" => "📋",
"md" => "📄",
"css" | "scss" | "sass" | "less" => "🎨",
"html" | "htm" => "🌐",
"sql" => "💾",
"sh" | "bash" | "zsh" => "🐚",
"dockerfile" => "📦",
"lock" => "🔒",
"env" => "🔑",
"gitignore" | "dockerignore" | "editorconfig" => "🚫",
_ => "📄",
}
}
fn build_file_tree(
files: &[FileDiff],
comments: &[crate::state::Comment],
expanded_dirs: &std::collections::HashSet<String>,
no_emoji: bool,
file_filter: Option<&str>,
) -> Vec<FileTreeEntry> {
use std::collections::BTreeMap;
struct DirNode {
children_dirs: BTreeMap<String, DirNode>,
files: Vec<usize>,
}
impl DirNode {
fn new() -> Self {
Self {
children_dirs: BTreeMap::new(),
files: Vec::new(),
}
}
fn has_files(&self) -> bool {
if !self.files.is_empty() {
return true;
}
self.children_dirs.values().any(|c| c.has_files())
}
}
let filter_lower = file_filter
.filter(|q| !q.is_empty())
.map(|q| q.to_lowercase());
let matches_filter = |path: &str| -> bool {
match &filter_lower {
Some(q) => path.to_lowercase().contains(q),
None => true,
}
};
let mut root = DirNode::new();
for (i, file) in files.iter().enumerate() {
if !matches_filter(&file.path) {
continue;
}
let parts: Vec<&str> = file.path.rsplitn(2, '/').collect();
if parts.len() == 2 {
let dir_path = parts[1];
let segments: Vec<&str> = dir_path.split('/').collect();
let mut node = &mut root;
for seg in &segments {
node = node
.children_dirs
.entry(seg.to_string())
.or_insert_with(DirNode::new);
}
node.files.push(i);
} else {
root.files.push(i);
}
}
fn render_tree(
node: &DirNode,
parent_path: &str,
depth: usize,
expanded_dirs: &std::collections::HashSet<String>,
files: &[FileDiff],
comments: &[crate::state::Comment],
no_emoji: bool,
) -> Vec<FileTreeEntry> {
let mut entries = Vec::new();
for (name, child) in &node.children_dirs {
if !child.has_files() {
continue;
}
let full_path = if parent_path.is_empty() {
name.clone()
} else {
format!("{}/{}", parent_path, name)
};
let is_expanded = expanded_dirs.contains(&full_path);
let collapse_icon = if no_emoji {
if is_expanded { "▾" } else { "▸" }
} else if is_expanded {
"\u{1f4c2}" } else {
"\u{1f4c1}" };
let label = format!("{collapse_icon} {name}/");
let children = if is_expanded {
render_tree(
child,
&full_path,
depth + 1,
expanded_dirs,
files,
comments,
no_emoji,
)
} else {
vec![]
};
entries.push(FileTreeEntry::Dir {
path: full_path,
label,
depth,
children,
});
}
for &file_idx in &node.files {
let file = &files[file_idx];
let filename = file.path.rsplit('/').next().unwrap_or(&file.path);
let n_comments = comments.iter().filter(|c| c.file == file.path).count();
let display = if no_emoji {
filename.to_string()
} else {
format!("{} {filename}", get_file_icon(filename))
};
let comment_badge = if n_comments == 0 {
String::new()
} else {
format!(" 朱{n_comments}")
};
entries.push(FileTreeEntry::FileInfo {
file_idx,
display,
depth,
badge: format!("{} ", file.status.badge()),
status: file.status,
counts: format!(" +{}/-{}", file.added, file.removed),
comment_badge,
viewed: file.viewed,
});
}
entries
}
render_tree(&root, "", 0, expanded_dirs, files, comments, no_emoji)
}
pub fn tree_file_order(files: &[FileDiff]) -> Vec<usize> {
use std::collections::BTreeMap;
struct Node {
dirs: BTreeMap<String, Node>,
files: Vec<usize>,
}
impl Node {
fn new() -> Self {
Self {
dirs: BTreeMap::new(),
files: Vec::new(),
}
}
}
let mut root = Node::new();
for (i, file) in files.iter().enumerate() {
let parts: Vec<&str> = file.path.rsplitn(2, '/').collect();
if parts.len() == 2 {
let mut node = &mut root;
for seg in parts[1].split('/') {
node = node.dirs.entry(seg.to_string()).or_insert_with(Node::new);
}
node.files.push(i);
} else {
root.files.push(i);
}
}
fn flatten(node: &Node, out: &mut Vec<usize>) {
for child in node.dirs.values() {
flatten(child, out);
}
out.extend(&node.files);
}
let mut out = Vec::with_capacity(files.len());
flatten(&root, &mut out);
out
}
pub fn file_tree_indices(
files: &[FileDiff],
comments: &[crate::state::Comment],
expanded_dirs: &std::collections::HashSet<String>,
file_filter: Option<&str>,
) -> Vec<Option<usize>> {
flatten_file_tree(files, comments, expanded_dirs, file_filter)
.into_iter()
.map(|e| match e {
FlatFileTreeEntry::Dir { .. } => None,
FlatFileTreeEntry::File { file_idx } => Some(file_idx),
})
.collect()
}
#[derive(Debug, Clone)]
pub enum FlatFileTreeEntry {
Dir { path: String },
File { file_idx: usize },
}
pub fn flatten_file_tree(
files: &[FileDiff],
comments: &[crate::state::Comment],
expanded_dirs: &std::collections::HashSet<String>,
file_filter: Option<&str>,
) -> Vec<FlatFileTreeEntry> {
let tree = build_file_tree(files, comments, expanded_dirs, false, file_filter);
let mut out: Vec<FlatFileTreeEntry> = Vec::new();
fn walk(entries: &[FileTreeEntry], out: &mut Vec<FlatFileTreeEntry>) {
for e in entries {
match e {
FileTreeEntry::Dir { path, children, .. } => {
out.push(FlatFileTreeEntry::Dir { path: path.clone() });
walk(children, out);
}
FileTreeEntry::FileInfo { file_idx, .. } => {
out.push(FlatFileTreeEntry::File {
file_idx: *file_idx,
});
}
}
}
}
walk(&tree, &mut out);
out
}
pub fn get_parent_dirs(file_path: &str) -> Vec<String> {
if let Some(parent) = std::path::Path::new(file_path).parent() {
let mut paths = Vec::new();
let mut cur = parent.as_os_str().to_string_lossy().into_owned();
loop {
if cur.is_empty() || cur == "." {
break;
}
paths.push(cur.clone());
if let Some(p) = std::path::Path::new(&cur).parent() {
cur = p.as_os_str().to_string_lossy().into_owned();
} else {
break;
}
}
paths.reverse();
paths
} else {
Vec::new()
}
}
fn render_diff(
f: &mut Frame,
app: &mut App,
subheader_inner: Rect,
body_inner: Rect,
theme: &Theme,
) {
render_diff_subheader(f, app, subheader_inner, theme, app.split_view);
if app.split_view {
render_split_diff(f, app, body_inner, theme);
return;
}
if body_inner.height == 0 {
return;
}
app.viewport_height = body_inner.height as usize;
let (lines, row_map) = match app.current() {
Some(file) => render_file_diff(
file,
app.diff_scroll,
body_inner.height as usize,
body_inner.width as usize,
app.cursor_line,
app,
theme,
),
None => (vec![Line::from("(no files)")], Vec::new()),
};
let paragraph = Paragraph::new(lines).style(Style::default().bg(theme.bg));
f.render_widget(paragraph, body_inner);
app.diff_row_map = row_map;
}
fn render_comments_preview_modal(f: &mut Frame, app: &App, theme: &Theme) {
use ratatui::widgets::{Block, Borders, Clear};
let area = centered_rect(80, 80, f.area());
f.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.title(Span::styled(
" Preview (stdout) ",
Style::default()
.fg(theme.bg)
.bg(theme.shuire)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(theme.shuire).bg(theme.bg))
.style(Style::default().bg(theme.bg));
let inner = block.inner(area);
f.render_widget(block, area);
if inner.width == 0 || inner.height == 0 {
return;
}
let text = app.format_all_comments();
let lines: Vec<Line> = if text.is_empty() {
vec![Line::from(Span::styled(
" (no comments — stdout output would be empty)".to_string(),
Style::default().fg(theme.dim_fg).bg(theme.bg),
))]
} else {
text.split('\n')
.map(|l| {
let style = if l.starts_with("> ") {
Style::default().fg(theme.dim_fg).bg(theme.bg)
} else if l == "=====" {
Style::default().fg(theme.shuire_dim).bg(theme.bg)
} else if l.contains(":L") {
Style::default()
.fg(theme.shuire)
.bg(theme.bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.context_fg).bg(theme.bg)
};
Line::from(Span::styled(l.to_string(), style))
})
.collect()
};
f.render_widget(
Paragraph::new(lines).style(Style::default().bg(theme.bg)),
inner,
);
}
fn render_diff_subheader(f: &mut Frame, app: &App, area: Rect, theme: &Theme, split: bool) {
let Some(file) = app.current() else {
let line = Line::from(Span::styled(
" (no diff) ",
Style::default().fg(theme.dim_fg).bg(theme.bg),
));
f.render_widget(
Paragraph::new(line).style(Style::default().bg(theme.bg)),
area,
);
return;
};
let (dir_part, file_part) = match file.path.rfind('/') {
Some(p) => (&file.path[..=p], &file.path[p + 1..]),
None => ("", file.path.as_str()),
};
let (pos, total) = if app.files.is_empty() {
(0, 0)
} else {
let order = tree_file_order(&app.files);
let p = order.iter().position(|&i| i == app.selected).unwrap_or(0);
(p + 1, app.files.len())
};
let stats = format!(" +{} −{} ({pos}/{total}) ", file.added, file.removed);
let bar_bg = theme.bg;
let (unified_fg, sbs_fg) = if split {
(theme.dim_fg, theme.shuire)
} else {
(theme.shuire, theme.dim_fg)
};
let spans = vec![
Span::styled(
" ━━ ",
Style::default()
.fg(theme.shuire)
.bg(bar_bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(
dir_part.to_string(),
Style::default().fg(theme.context_fg).bg(bar_bg),
),
Span::styled(
file_part.to_string(),
Style::default()
.fg(theme.shuire)
.bg(bar_bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(stats, Style::default().fg(theme.dim_fg).bg(bar_bg)),
Span::styled("│", Style::default().fg(theme.border_unfocused).bg(bar_bg)),
Span::styled(
" unified ",
Style::default()
.fg(unified_fg)
.bg(bar_bg)
.add_modifier(if !split {
Modifier::BOLD
} else {
Modifier::empty()
}),
),
Span::styled("│", Style::default().fg(theme.border_unfocused).bg(bar_bg)),
Span::styled(
" side-by-side ",
Style::default()
.fg(sbs_fg)
.bg(bar_bg)
.add_modifier(if split {
Modifier::BOLD
} else {
Modifier::empty()
}),
),
];
let line = Line::from(spans).style(Style::default().bg(bar_bg));
f.render_widget(Paragraph::new(line), area);
}
fn render_split_diff(f: &mut Frame, app: &mut App, body_inner: Rect, theme: &Theme) {
if body_inner.height == 0 || body_inner.width < 21 {
return;
}
let halves = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(10),
Constraint::Length(1),
Constraint::Min(10),
])
.split(body_inner);
let left_inner = halves[0];
let right_inner = halves[2];
let sep_col_x = halves[1].x;
let sep_style = Style::default().fg(theme.border_unfocused).bg(theme.bg);
let sep_lines: Vec<Line> = (0..body_inner.height)
.map(|_| Line::from(Span::styled("│", sep_style)))
.collect();
f.render_widget(
Paragraph::new(sep_lines).style(Style::default().bg(theme.bg)),
Rect {
x: sep_col_x,
y: body_inner.y,
width: 1,
height: body_inner.height,
},
);
app.viewport_height = left_inner.height as usize;
let file = match app.current() {
Some(f) => f,
None => {
let p = Paragraph::new("(no files)").style(Style::default().bg(theme.bg));
f.render_widget(p.clone(), halves[0]);
f.render_widget(p, halves[2]);
return;
}
};
let split_rows = build_split_rows(file);
let scroll_row = split_rows
.iter()
.position(|r| r.left == Some(app.diff_scroll) || r.right == Some(app.diff_scroll))
.unwrap_or(0);
let height = left_inner.height as usize;
let lw = left_inner.width as usize;
let rw = right_inner.width as usize;
let mut left_lines: Vec<Line> = Vec::new();
let mut right_lines: Vec<Line> = Vec::new();
let blank_line = || Line::from(Span::raw(""));
let empty_side_line =
|w: usize| Line::from(Span::raw(" ".repeat(w))).style(Style::default().bg(theme.bg_alt));
let search_needle_lower: Option<String> = app.search_query.as_deref().map(str::to_lowercase);
for row in split_rows.iter().skip(scroll_row) {
if left_lines.len() >= height {
break;
}
let left = build_split_side_line(
row.left,
file,
app,
lw,
true,
search_needle_lower.as_deref(),
theme,
);
let right = build_split_side_line(
row.right,
file,
app,
rw,
false,
search_needle_lower.as_deref(),
theme,
);
let max_wrap = left.len().max(right.len());
for ri in 0..max_wrap {
if left_lines.len() >= height {
break;
}
left_lines.push(left.get(ri).cloned().unwrap_or_else(|| empty_side_line(lw)));
right_lines.push(
right
.get(ri)
.cloned()
.unwrap_or_else(|| empty_side_line(rw)),
);
}
let left_comments: Vec<&crate::state::Comment> = row
.left
.map(|i| app.comments_on(file, i))
.unwrap_or_default()
.into_iter()
.filter(|c| c.side == crate::state::Side::Old)
.collect();
let right_comments: Vec<&crate::state::Comment> = row
.right
.map(|i| app.comments_on(file, i))
.unwrap_or_default()
.into_iter()
.filter(|c| c.side == crate::state::Side::New)
.collect();
let cursor_idx = app.cursor_line;
let cursor_on_left = row.left == Some(cursor_idx) && row.right != Some(cursor_idx);
let cursor_on_right = row.right == Some(cursor_idx);
let max_comments = left_comments.len().max(right_comments.len());
for ci in 0..max_comments {
if left_lines.len() >= height {
break;
}
let mut left_block: Vec<Line> = Vec::new();
let mut right_block: Vec<Line> = Vec::new();
if let Some(c) = left_comments.get(ci) {
let is_focused = cursor_on_left && app.comment_focus == Some(ci);
let loc = c.location();
push_comment_block(
&mut left_block,
height,
lw,
SPLIT_GUTTER_WIDTH,
Some(&loc),
&c.body,
is_focused,
theme,
);
}
if let Some(c) = right_comments.get(ci) {
let is_focused = cursor_on_right && app.comment_focus == Some(ci);
let loc = c.location();
push_comment_block(
&mut right_block,
height,
rw,
SPLIT_GUTTER_WIDTH,
Some(&loc),
&c.body,
is_focused,
theme,
);
}
let max_rows = left_block.len().max(right_block.len());
for ri in 0..max_rows {
if left_lines.len() >= height {
break;
}
left_lines.push(left_block.get(ri).cloned().unwrap_or_else(&blank_line));
right_lines.push(right_block.get(ri).cloned().unwrap_or_else(&blank_line));
}
}
}
let lp = Paragraph::new(left_lines).style(Style::default().bg(theme.bg));
let rp = Paragraph::new(right_lines).style(Style::default().bg(theme.bg));
f.render_widget(lp, halves[0]);
f.render_widget(rp, halves[2]);
}
pub(crate) const SPLIT_GUTTER_WIDTH: usize = 6;
fn code_line_style(
diff_line: &crate::diff::DiffLine,
theme: &Theme,
) -> (
&'static str,
ratatui::style::Color,
Option<ratatui::style::Color>,
) {
match diff_line.kind {
LineKind::Added => ("+", theme.added_fg, Some(theme.added_bg)),
LineKind::Removed => ("-", theme.removed_fg, Some(theme.removed_bg)),
_ => (" ", theme.context_fg, None),
}
}
fn build_split_side_line(
line_idx: Option<usize>,
file: &FileDiff,
app: &App,
width: usize,
is_left: bool,
search_needle_lower: Option<&str>,
theme: &Theme,
) -> Vec<Line<'static>> {
let Some(idx) = line_idx else {
return vec![
Line::from(Span::raw(" ".repeat(width))).style(Style::default().bg(theme.bg_alt)),
];
};
let diff_line = &file.lines[idx];
let is_cursor = idx == app.cursor_line;
let side = if is_left {
crate::state::Side::Old
} else {
crate::state::Side::New
};
let underline = app.underline_kind_for_side(file, idx, side);
let gutter_num = if is_left {
diff_line.old_lineno
} else {
diff_line.new_lineno
};
let gutter = match gutter_num {
Some(n) => format!("{:>4} ", n),
None => " ".to_string(),
};
if diff_line.kind.is_fold() {
let style = if is_cursor {
Style::default()
.fg(theme.fold_fg)
.bg(theme.cursor_blend(theme.header_bg))
} else {
Style::default().fg(theme.fold_fg).bg(theme.header_bg)
};
let cursor_header_bg = if is_cursor {
theme.cursor_blend(theme.header_bg)
} else {
theme.header_bg
};
let arrow = match diff_line.kind {
LineKind::FoldDown => "▼",
LineKind::FoldUp => "▲",
_ => " ",
};
let mut spans = vec![
Span::styled(
gutter,
Style::default().fg(theme.gutter_fg).bg(cursor_header_bg),
),
Span::styled(" ", Style::default().bg(cursor_header_bg)),
Span::styled(format!("{arrow} {:>3} lines ", diff_line.fold_count), style),
];
if !is_left && !diff_line.hunk_header.is_empty() {
let (range, ctx) = split_hunk_header(&diff_line.hunk_header);
spans.push(Span::styled(
range,
Style::default().fg(theme.header_range_fg),
));
if !ctx.is_empty() {
spans.push(Span::styled(
ctx,
Style::default()
.fg(theme.header_context_fg)
.add_modifier(Modifier::BOLD),
));
}
}
pad_line_bg(&mut spans, width, cursor_header_bg);
return vec![Line::from(spans).style(Style::default().bg(cursor_header_bg))];
}
if diff_line.kind == LineKind::HunkHeader {
let (range, ctx) = split_hunk_header(&diff_line.text);
let banner_bg = theme.bg_alt;
let mut spans = vec![
Span::styled(" ", Style::default().bg(banner_bg)),
Span::styled(gutter, Style::default().fg(theme.gutter_fg).bg(banner_bg)),
Span::styled(
range,
Style::default()
.fg(theme.header_range_fg)
.bg(banner_bg)
.add_modifier(Modifier::BOLD),
),
];
if !ctx.is_empty() {
spans.push(Span::styled(
ctx,
Style::default()
.fg(theme.header_range_fg)
.bg(banner_bg)
.add_modifier(Modifier::BOLD),
));
}
pad_line_bg(&mut spans, width, banner_bg);
return vec![Line::from(spans).style(Style::default().bg(banner_bg))];
}
let (prefix, kind_fg, bg) = code_line_style(diff_line, theme);
let effective_bg = if is_cursor {
Some(match bg {
Some(b) => theme.cursor_blend(b),
None => theme.cursor_bg,
})
} else {
bg
};
let mut spans: Vec<Span<'static>> = Vec::new();
let mut gs = Style::default().fg(theme.gutter_fg);
if let Some(b) = effective_bg {
gs = gs.bg(b);
}
spans.push(Span::styled(gutter, gs));
let mut ps = Style::default().fg(kind_fg);
if let Some(b) = effective_bg {
ps = ps.bg(b);
}
spans.push(Span::styled(prefix.to_string(), ps));
push_code_spans(
&mut spans,
diff_line,
kind_fg,
effective_bg,
is_cursor,
search_needle_lower,
theme,
);
push_no_newline_marker(&mut spans, diff_line, effective_bg, theme);
let base_style = match effective_bg {
Some(b) => Style::default().bg(b),
None => Style::default(),
};
let mut lines = wrap_line(spans, width, SPLIT_GUTTER_WIDTH, base_style);
if let Some(color) = underline_color(underline, theme) {
apply_comment_underline(&mut lines, color, SPLIT_GUTTER_WIDTH);
}
if let Some(b) = effective_bg {
for line in &mut lines {
pad_line_bg(&mut line.spans, width, b);
}
}
lines
}
fn underline_color(
kind: crate::state::UnderlineKind,
theme: &Theme,
) -> Option<ratatui::style::Color> {
match kind {
crate::state::UnderlineKind::None => None,
crate::state::UnderlineKind::Dim => Some(theme.comment_bar_dim),
crate::state::UnderlineKind::Bright => Some(theme.comment_bar),
}
}
fn apply_comment_underline(
lines: &mut [Line<'static>],
color: ratatui::style::Color,
gutter_width: usize,
) {
for line in lines.iter_mut() {
let mut consumed = 0usize;
for span in line.spans.iter_mut() {
if consumed >= gutter_width {
span.style = span
.style
.add_modifier(Modifier::UNDERLINED)
.underline_color(color);
}
consumed += span.content.width();
}
}
}
pub(crate) const GUTTER_INDENT: &str = " ";
fn find_case_insensitive_ranges(haystack: &str, needle_lower: &str) -> Vec<(usize, usize)> {
if needle_lower.is_empty() {
return Vec::new();
}
let hay_lower = haystack.to_lowercase();
let mut out = Vec::new();
let mut start = 0;
while let Some(rel) = hay_lower[start..].find(needle_lower) {
let s = start + rel;
let e = s + needle_lower.len();
if e > haystack.len() {
break;
}
out.push((s, e));
start = e;
}
out
}
fn push_code_spans(
spans: &mut Vec<Span<'static>>,
diff_line: &crate::diff::DiffLine,
kind_fg: ratatui::style::Color,
effective_bg: Option<ratatui::style::Color>,
is_cursor: bool,
search_needle_lower: Option<&str>,
theme: &Theme,
) {
let word_bg = match diff_line.kind {
LineKind::Added => Some(theme.added_word_bg),
LineKind::Removed => Some(theme.removed_word_bg),
_ => None,
};
let effective_word_bg = if is_cursor {
word_bg.map(|b| theme.cursor_blend(b))
} else {
word_bg
};
let search_ranges: Vec<(usize, usize)> = search_needle_lower
.filter(|q| !q.is_empty())
.map(|q| find_case_insensitive_ranges(&diff_line.text, q))
.unwrap_or_default();
let search_bg = if is_cursor {
theme.cursor_blend(theme.mode_search_bg)
} else {
theme.mode_search_bg
};
let in_word_hl = |offset: usize| -> bool {
diff_line
.word_highlights
.iter()
.any(|(s, e)| offset >= *s && offset < *e)
};
let in_search_hl = |offset: usize| -> bool {
search_ranges
.iter()
.any(|(s, e)| offset >= *s && offset < *e)
};
let push_chunk = |spans: &mut Vec<Span<'static>>,
text: &str,
start_offset: usize,
fg: ratatui::style::Color| {
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
let start = i;
let start_word = in_word_hl(start_offset + start);
let start_search = in_search_hl(start_offset + start);
let mut j = start + 1;
while j < bytes.len() && (bytes[j] & 0xC0) == 0x80 {
j += 1;
}
while j < bytes.len()
&& in_word_hl(start_offset + j) == start_word
&& in_search_hl(start_offset + j) == start_search
{
let mut k = j + 1;
while k < bytes.len() && (bytes[k] & 0xC0) == 0x80 {
k += 1;
}
j = k;
}
let bg = if start_search {
Some(search_bg)
} else if start_word {
effective_word_bg.or(effective_bg)
} else {
effective_bg
};
let mut style = Style::default().fg(fg);
if let Some(b) = bg {
style = style.bg(b);
}
spans.push(Span::styled(text[start..j].to_string(), style));
i = j;
}
};
if !diff_line.segments.is_empty() {
let mut offset = 0usize;
for (kind, text) in &diff_line.segments {
let fg = theme.syntax.color(*kind);
push_chunk(spans, text, offset, fg);
offset += text.len();
}
} else {
push_chunk(spans, &diff_line.text, 0, kind_fg);
}
}
fn render_file_diff<'a>(
file: &'a FileDiff,
scroll: usize,
height: usize,
width: usize,
cursor_line: usize,
app: &'a App,
theme: &Theme,
) -> (Vec<Line<'a>>, Vec<usize>) {
let mut out: Vec<Line<'a>> = Vec::new();
let mut row_map: Vec<usize> = Vec::new();
let search_needle_lower: Option<String> = app.search_query.as_deref().map(str::to_lowercase);
let mut idx = scroll;
while idx < file.lines.len() && out.len() < height {
let diff_line = &file.lines[idx];
let is_cursor = idx == cursor_line;
let code_active = is_cursor && app.comment_focus.is_none();
let is_visual = app.is_in_visual(idx);
let comments = app.comments_on(file, idx);
let underline = app.underline_kind(file, idx);
let code_lines = build_code_line(
diff_line,
code_active,
is_visual,
underline,
width,
search_needle_lower.as_deref(),
theme,
);
for cl in code_lines {
if out.len() >= height {
break;
}
out.push(cl);
row_map.push(idx);
}
for (ci, c) in comments.iter().enumerate() {
let is_focused = is_cursor && app.comment_focus == Some(ci);
let before = out.len();
let loc = c.location();
push_comment_block(
&mut out,
height,
width,
GUTTER_INDENT.len(),
Some(&loc),
&c.body,
is_focused,
theme,
);
for _ in before..out.len() {
row_map.push(idx);
}
for reply in &c.replies {
if out.len() >= height {
break;
}
let before = out.len();
push_reply_block(&mut out, height, width, GUTTER_INDENT.len(), reply, theme);
for _ in before..out.len() {
row_map.push(idx);
}
}
if out.len() >= height {
break;
}
}
if !app.comment_modal
&& app.mode == Mode::Insert
&& is_cursor
&& app.editing_comment.is_none()
&& out.len() < height
{
push_inline_input(&mut out, height, &app.input, app.input_cursor, theme);
let input_rows = app.input.matches('\n').count() + 1;
for _ in 0..input_rows {
row_map.push(idx);
}
}
idx += 1;
}
(out, row_map)
}
#[derive(Clone, Copy, PartialEq)]
enum BodySegKind {
Text,
SuggestionNew,
}
struct BodySeg<'a> {
kind: BodySegKind,
text: &'a str,
}
fn parse_comment_body<'a>(body: &'a str) -> Vec<BodySeg<'a>> {
let mut out = Vec::new();
let mut in_suggestion = false;
for line in body.split('\n') {
let trimmed = line.trim_start();
if !in_suggestion && (trimmed == "```suggestion" || trimmed.starts_with("```suggestion ")) {
in_suggestion = true;
continue;
}
if in_suggestion && trimmed == "```" {
in_suggestion = false;
continue;
}
let kind = if in_suggestion {
BodySegKind::SuggestionNew
} else {
BodySegKind::Text
};
out.push(BodySeg { kind, text: line });
}
out
}
fn render_markdown_line(
text: &str,
base_fg: ratatui::style::Color,
bg: ratatui::style::Color,
) -> Vec<Span<'static>> {
let base = Style::default().fg(base_fg).bg(bg);
let bold_like = base.add_modifier(Modifier::BOLD);
let mut spans: Vec<Span<'static>> = Vec::new();
let (line_style, rest) = if let Some(rest) = text
.strip_prefix("### ")
.or_else(|| text.strip_prefix("## "))
.or_else(|| text.strip_prefix("# "))
{
(bold_like, rest)
} else if let Some(rest) = text.strip_prefix("> ") {
spans.push(Span::styled(
"┃ ".to_string(),
base.add_modifier(Modifier::DIM),
));
(base.add_modifier(Modifier::ITALIC), rest)
} else if let Some(rest) = text.strip_prefix("- ").or_else(|| text.strip_prefix("* ")) {
spans.push(Span::styled("• ".to_string(), base));
(base, rest)
} else {
(base, text)
};
let bytes = rest.as_bytes();
let mut i = 0;
let mut buf = String::new();
while i < bytes.len() {
if bytes[i] == b'`' {
if !buf.is_empty() {
spans.push(Span::styled(std::mem::take(&mut buf), line_style));
}
let start = i + 1;
let mut j = start;
while j < bytes.len() && bytes[j] != b'`' {
j += 1;
}
spans.push(Span::styled(
rest[start..j].to_string(),
base.add_modifier(Modifier::REVERSED),
));
i = if j < bytes.len() { j + 1 } else { j };
continue;
}
if i + 1 < bytes.len() && bytes[i] == b'*' && bytes[i + 1] == b'*' {
if !buf.is_empty() {
spans.push(Span::styled(std::mem::take(&mut buf), line_style));
}
let start = i + 2;
let mut j = start;
while j + 1 < bytes.len() && !(bytes[j] == b'*' && bytes[j + 1] == b'*') {
j += 1;
}
spans.push(Span::styled(
rest[start..j].to_string(),
line_style.add_modifier(Modifier::BOLD),
));
i = if j + 1 < bytes.len() {
j + 2
} else {
bytes.len()
};
continue;
}
if bytes[i] == b'*' && i + 1 < bytes.len() && bytes[i + 1] != b' ' && bytes[i + 1] != b'*' {
if !buf.is_empty() {
spans.push(Span::styled(std::mem::take(&mut buf), line_style));
}
let start = i + 1;
let mut j = start;
while j < bytes.len() && bytes[j] != b'*' {
j += 1;
}
spans.push(Span::styled(
rest[start..j].to_string(),
line_style.add_modifier(Modifier::ITALIC),
));
i = if j < bytes.len() { j + 1 } else { j };
continue;
}
let ch_len = rest[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1);
buf.push_str(&rest[i..i + ch_len]);
i += ch_len;
}
if !buf.is_empty() {
spans.push(Span::styled(buf, line_style));
}
spans
}
#[allow(clippy::too_many_arguments)]
fn push_comment_block<'a>(
out: &mut Vec<Line<'a>>,
height: usize,
width: usize,
gutter_width: usize,
header_label: Option<&str>,
body: &str,
is_focused: bool,
theme: &Theme,
) {
let bg = if is_focused {
theme.cursor_blend(theme.comment_bg)
} else {
theme.comment_bg
};
let border_color = if is_focused {
theme.shuire
} else {
theme.shuire_dim
};
let text_fg = theme.comment_fg;
let indent = &GUTTER_INDENT[..gutter_width.min(GUTTER_INDENT.len())];
let box_width = width.saturating_sub(indent.width());
if box_width < 4 {
return;
}
let inner_w = box_width - 2;
let border_style = Style::default().fg(border_color).bg(bg);
if out.len() >= height {
return;
}
push_box_border_line(out, indent, '╭', '╮', inner_w, border_style, width, bg);
if let Some(label) = header_label {
if out.len() < height {
let mut spans = vec![
Span::styled(indent, Style::default().bg(bg)),
Span::styled("│ ", border_style),
Span::styled(
label.to_string(),
Style::default()
.fg(theme.shuire)
.bg(bg)
.add_modifier(Modifier::BOLD),
),
];
pad_to_box_right(&mut spans, width, bg, border_style);
out.push(Line::from(spans).style(Style::default().bg(bg)));
}
}
for seg in parse_comment_body(body) {
if out.len() >= height {
return;
}
let mut spans = vec![
Span::styled(indent, Style::default().bg(bg)),
Span::styled("│ ".to_string(), border_style),
];
match seg.kind {
BodySegKind::Text => {
spans.extend(render_markdown_line(seg.text, text_fg, bg));
}
BodySegKind::SuggestionNew => {
spans.push(Span::styled(
"+ ".to_string(),
Style::default().fg(theme.added_fg).bg(theme.added_bg),
));
spans.push(Span::styled(
seg.text.to_string(),
Style::default().fg(theme.added_fg).bg(theme.added_bg),
));
}
}
pad_to_box_right(&mut spans, width, bg, border_style);
out.push(Line::from(spans).style(Style::default().bg(bg)));
}
if out.len() < height {
push_box_border_line(out, indent, '╰', '╯', inner_w, border_style, width, bg);
}
}
#[allow(clippy::too_many_arguments)]
fn push_box_border_line<'a>(
out: &mut Vec<Line<'a>>,
indent: &'a str,
left_corner: char,
right_corner: char,
inner_w: usize,
border_style: Style,
width: usize,
bg: ratatui::style::Color,
) {
let mut border = String::with_capacity(inner_w + 2);
border.push(left_corner);
for _ in 0..inner_w {
border.push('─');
}
border.push(right_corner);
let mut spans = vec![
Span::styled(indent, Style::default().bg(bg)),
Span::styled(border, border_style),
];
pad_line_bg(&mut spans, width, bg);
out.push(Line::from(spans).style(Style::default().bg(bg)));
}
fn pad_to_box_right<'a>(
spans: &mut Vec<Span<'a>>,
width: usize,
bg: ratatui::style::Color,
border_style: Style,
) {
let used: usize = spans.iter().map(|s| s.content.width()).sum();
if used + 2 >= width {
pad_line_bg(spans, width, bg);
return;
}
let gap = width - used - 1;
spans.push(Span::styled(" ".repeat(gap), Style::default().bg(bg)));
spans.push(Span::styled("│".to_string(), border_style));
}
fn push_reply_block<'a>(
out: &mut Vec<Line<'a>>,
height: usize,
width: usize,
gutter_width: usize,
body: &str,
theme: &Theme,
) {
let bg = theme.comment_bg;
let text_fg = theme.comment_fg;
let border_style = Style::default().fg(theme.shuire_dim).bg(bg);
let indent = &GUTTER_INDENT[..gutter_width.min(GUTTER_INDENT.len())];
let indent_w = indent.width();
let box_width = width.saturating_sub(indent_w + 2);
if box_width < 4 {
return;
}
for (i, text) in body.split('\n').enumerate() {
if out.len() >= height {
return;
}
let marker = if i == 0 { "↳ " } else { " " };
let mut spans = vec![
Span::styled(indent, Style::default().bg(bg)),
Span::styled(" ".to_string(), Style::default().bg(bg)),
Span::styled("│ ".to_string(), border_style),
Span::styled(
marker.to_string(),
Style::default().fg(theme.shuire_dim).bg(bg),
),
];
spans.extend(render_markdown_line(text, text_fg, bg));
pad_to_box_right(&mut spans, width, bg, border_style);
out.push(Line::from(spans).style(Style::default().bg(bg)));
}
}
fn push_inline_input<'a>(
out: &mut Vec<Line<'a>>,
height: usize,
input: &'a str,
input_cursor: usize,
theme: &Theme,
) {
let input_style = Style::default().fg(theme.input_fg).bg(theme.input_bg);
let marker_style = Style::default().fg(theme.input_bar);
let caret_style = Style::default().fg(theme.caret_fg).bg(theme.caret_bg);
let mut byte_offset: usize = 0;
for (i, line_text) in input.split('\n').enumerate() {
if out.len() >= height {
break;
}
let marker = if i == 0 { "▸ " } else { "│ " };
let line_start = byte_offset;
let line_end = line_start + line_text.len();
let cursor_in_line = input_cursor >= line_start && input_cursor <= line_end;
let mut spans = vec![Span::raw(GUTTER_INDENT), Span::styled(marker, marker_style)];
if cursor_in_line {
let local = input_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(), ""),
};
spans.push(Span::styled(before, input_style));
spans.push(Span::styled(glyph, caret_style));
spans.push(Span::styled(after, input_style));
} else {
spans.push(Span::styled(line_text, input_style));
}
out.push(Line::from(spans));
byte_offset = line_end + 1;
}
}
fn build_code_line(
diff_line: &crate::diff::DiffLine,
is_cursor: bool,
is_visual: bool,
underline: crate::state::UnderlineKind,
width: usize,
search_needle_lower: Option<&str>,
theme: &Theme,
) -> Vec<Line<'static>> {
if diff_line.kind.is_fold() {
return vec![build_fold_line(diff_line, is_cursor, width, theme)];
}
if diff_line.kind == LineKind::HunkHeader {
return vec![build_hunk_header_line(diff_line, width, theme)];
}
let (prefix, kind_fg, kind_bg) = code_line_style(diff_line, theme);
let effective_bg = if is_cursor {
Some(match kind_bg {
Some(b) => theme.cursor_blend(b),
None => theme.cursor_bg,
})
} else if is_visual {
Some(match kind_bg {
Some(b) => mix_color(b, theme.visual_bg, 0.5),
None => theme.visual_bg,
})
} else {
kind_bg
};
let mut spans: Vec<Span<'static>> = Vec::with_capacity(4 + diff_line.segments.len().max(1));
let mut anchor_style = Style::default().fg(theme.dim_fg);
if let Some(b) = effective_bg {
anchor_style = anchor_style.bg(b);
}
spans.push(Span::styled(" ", anchor_style));
let mut gutter_style = Style::default().fg(theme.gutter_fg);
if let Some(b) = effective_bg {
gutter_style = gutter_style.bg(b);
}
spans.push(Span::styled(diff_line.gutter.clone(), gutter_style));
let mut marker_style = Style::default();
if let Some(b) = effective_bg {
marker_style = marker_style.bg(b);
}
spans.push(Span::styled(" ", marker_style));
let mut prefix_style = Style::default().fg(kind_fg);
if let Some(b) = effective_bg {
prefix_style = prefix_style.bg(b);
}
spans.push(Span::styled(prefix.to_string(), prefix_style));
push_code_spans(
&mut spans,
diff_line,
kind_fg,
effective_bg,
is_cursor,
search_needle_lower,
theme,
);
push_no_newline_marker(&mut spans, diff_line, effective_bg, theme);
let base_style = match effective_bg {
Some(b) => Style::default().bg(b),
None => Style::default(),
};
let mut lines = wrap_line(spans, width, GUTTER_INDENT.len(), base_style);
if let Some(color) = underline_color(underline, theme) {
apply_comment_underline(&mut lines, color, GUTTER_INDENT.len());
}
if let Some(b) = effective_bg {
for line in &mut lines {
pad_line_bg(&mut line.spans, width, b);
}
}
lines
}
fn push_no_newline_marker(
spans: &mut Vec<Span<'static>>,
diff_line: &crate::diff::DiffLine,
effective_bg: Option<ratatui::style::Color>,
theme: &Theme,
) {
if !diff_line.no_newline {
return;
}
let mut style = Style::default().fg(theme.dim_fg);
if let Some(b) = effective_bg {
style = style.bg(b);
}
spans.push(Span::styled(" ⏎no newline at EOF", style));
}
fn build_fold_line(
diff_line: &crate::diff::DiffLine,
is_cursor: bool,
width: usize,
theme: &Theme,
) -> Line<'static> {
let line_bg = if is_cursor {
theme.cursor_blend(theme.header_bg)
} else {
theme.header_bg
};
let fold_style = Style::default().fg(theme.fold_fg).bg(line_bg);
let arrow = match diff_line.kind {
LineKind::FoldDown => "▼",
LineKind::FoldUp => "▲",
_ => " ",
};
let mut spans = vec![
Span::styled(" ", Style::default().bg(line_bg)),
Span::styled(
diff_line.gutter.clone(),
Style::default().fg(theme.gutter_fg).bg(line_bg),
),
Span::styled(" ", Style::default().bg(line_bg)),
Span::styled(
format!("{arrow} {:>3} lines ", diff_line.fold_count),
fold_style,
),
];
if !diff_line.hunk_header.is_empty() {
let (range_part, context_part) = split_hunk_header(&diff_line.hunk_header);
spans.push(Span::styled(
range_part,
Style::default().fg(theme.header_range_fg),
));
if !context_part.is_empty() {
spans.push(Span::styled(
context_part,
Style::default()
.fg(theme.header_context_fg)
.add_modifier(Modifier::BOLD),
));
}
}
let line_bg = if is_cursor {
theme.cursor_bg
} else {
theme.header_bg
};
truncate_spans(&mut spans, width);
pad_line_bg(&mut spans, width, line_bg);
Line::from(spans).style(Style::default().bg(line_bg))
}
fn build_hunk_header_line(
diff_line: &crate::diff::DiffLine,
width: usize,
theme: &Theme,
) -> Line<'static> {
let (range_part, context_part) = split_hunk_header(&diff_line.text);
let banner_bg = theme.bg_alt;
let mut spans = vec![
Span::styled(" ", Style::default().bg(banner_bg)),
Span::styled(
diff_line.gutter.clone(),
Style::default().fg(theme.gutter_fg).bg(banner_bg),
),
Span::styled(
range_part,
Style::default()
.fg(theme.header_range_fg)
.bg(banner_bg)
.add_modifier(Modifier::BOLD),
),
];
if !context_part.is_empty() {
spans.push(Span::styled(
context_part,
Style::default()
.fg(theme.header_range_fg)
.bg(banner_bg)
.add_modifier(Modifier::BOLD),
));
}
truncate_spans(&mut spans, width);
pad_line_bg(&mut spans, width, banner_bg);
Line::from(spans).style(Style::default().bg(banner_bg))
}
pub(crate) fn byte_offset_at_width(s: &str, max_width: usize) -> usize {
let mut byte_pos = 0;
let mut w = 0;
for ch in s.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if w + cw > max_width {
break;
}
w += cw;
byte_pos += ch.len_utf8();
}
byte_pos
}
fn split_spans_at<'a>(spans: Vec<Span<'a>>, max_width: usize) -> (Vec<Span<'a>>, Vec<Span<'a>>) {
let mut before = Vec::new();
let mut after = Vec::new();
let mut used = 0usize;
let mut exceeded = false;
for span in spans {
if exceeded {
after.push(span);
continue;
}
let w = span.content.width();
if used + w <= max_width {
before.push(span);
used += w;
} else {
let remaining = max_width - used;
let content_str: &str = &span.content;
let byte_pos = byte_offset_at_width(content_str, remaining);
if byte_pos > 0 {
before.push(Span::styled(
content_str[..byte_pos].to_string(),
span.style,
));
}
if byte_pos < content_str.len() {
after.push(Span::styled(
content_str[byte_pos..].to_string(),
span.style,
));
}
exceeded = true;
}
}
(before, after)
}
fn wrap_line<'a>(
spans: Vec<Span<'a>>,
width: usize,
indent: usize,
base_style: Style,
) -> Vec<Line<'a>> {
if width == 0 {
return vec![Line::from(spans).style(base_style)];
}
let total: usize = spans.iter().map(|s| s.content.width()).sum();
if total <= width {
return vec![Line::from(spans).style(base_style)];
}
let mut result: Vec<Line<'a>> = Vec::new();
let mut remaining = spans;
let mut is_first = true;
while !remaining.is_empty() {
let avail = if is_first {
width
} else {
width.saturating_sub(indent)
};
if avail == 0 {
break;
}
let (taken, rest) = split_spans_at(remaining, avail);
if taken.is_empty() {
break;
}
let line_spans = if is_first {
taken
} else {
let mut v = Vec::with_capacity(1 + taken.len());
v.push(Span::styled(&GUTTER_INDENT[..indent], base_style));
v.extend(taken);
v
};
result.push(Line::from(line_spans).style(base_style));
remaining = rest;
is_first = false;
}
if result.is_empty() {
result.push(Line::default().style(base_style));
}
result
}
fn truncate_spans(spans: &mut Vec<Span<'static>>, width: usize) {
let mut used = 0usize;
let mut truncate_at = None;
for (i, span) in spans.iter().enumerate() {
let w = span.content.width();
if used + w > width {
truncate_at = Some((i, width - used));
break;
}
used += w;
}
if let Some((idx, remaining_chars)) = truncate_at {
let content = &spans[idx].content;
let byte_end = byte_offset_at_width(content, remaining_chars);
let style = spans[idx].style;
let truncated = content[..byte_end].to_string();
spans[idx] = Span::styled(truncated, style);
spans.truncate(idx + 1);
}
}
fn pad_line_bg<'a>(spans: &mut Vec<Span<'a>>, width: usize, bg: ratatui::style::Color) {
let used: usize = spans.iter().map(|s| s.content.width()).sum();
let pad = width.saturating_sub(used);
if pad > 0 {
spans.push(Span::styled(" ".repeat(pad), Style::default().bg(bg)));
}
}
fn split_hunk_header(line: &str) -> (String, String) {
if let Some(first) = line.find("@@") {
if let Some(second) = line[first + 2..].find("@@") {
let range_end = first + 2 + second + 2;
let range = line[..range_end].to_string();
let context = line[range_end..].to_string();
return (range, context);
}
}
(line.to_string(), String::new())
}
pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}