use crate::app::{AnchorSide, App, Focus, LayoutCache};
use crate::config::Theme;
use crate::diff::{AlignSegment, LineKind, PaneLine, Segment, SegmentKind};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
Frame,
};
pub fn render(f: &mut Frame, app: &mut App) {
let area = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
])
.split(area);
render_title(f, chunks[0], app);
let mut layout = LayoutCache::default();
if app.show_files_panel {
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(app.config.layout.files_panel_width_pct),
Constraint::Min(20),
])
.split(chunks[1]);
render_files(f, body[0], app, &mut layout);
render_diff(f, body[1], app, &mut layout);
} else {
render_diff(f, chunks[1], app, &mut layout);
}
app.layout = layout;
render_status(f, chunks[2], app);
if app.show_help {
render_help(f, area, app);
}
if app
.review
.as_ref()
.and_then(|r| r.modal.as_ref())
.is_some()
{
render_review_modal(f, area, app);
}
}
fn render_review_modal(f: &mut Frame, area: Rect, app: &App) {
use crate::review_session::Modal;
let theme = &app.config.theme;
let Some(review) = app.review.as_ref() else {
return;
};
let Some(modal) = review.modal.as_ref() else {
return;
};
let w = (area.width as u32 * 70 / 100).clamp(40, 100) as u16;
let h = (area.height as u32 * 60 / 100).clamp(8, 30) as u16;
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
let r = Rect::new(x, y, w, h);
f.render_widget(Clear, r);
match modal {
Modal::Comment(m) => {
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Comment · {}:{} ", m.path, m.line))
.border_style(Style::default().fg(theme.help_border_fg))
.style(Style::default().bg(theme.help_panel_bg));
let inner = block.inner(r);
f.render_widget(block, r);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(inner);
f.render_widget(&m.textarea, layout[0]);
let hint = Line::from(vec![
Span::styled(" Esc ", Style::default().fg(theme.help_keys_fg)),
Span::styled("cancel ", Style::default().fg(theme.help_desc_fg)),
Span::styled(" Ctrl-S ", Style::default().fg(theme.help_keys_fg)),
Span::styled("save draft", Style::default().fg(theme.help_desc_fg)),
]);
f.render_widget(Paragraph::new(hint).style(Style::default().bg(theme.help_panel_bg)), layout[1]);
}
Modal::Drafts(dm) => {
let block = Block::default()
.borders(Borders::ALL)
.title(format!(
" Drafts · {} pending ",
review.drafts.len(),
))
.border_style(Style::default().fg(theme.help_border_fg))
.style(Style::default().bg(theme.help_panel_bg));
let inner = block.inner(r);
f.render_widget(block, r);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(inner);
let items: Vec<ListItem> = review
.drafts
.iter()
.map(|d| {
let side = match d.side {
review_protocol::CommentSide::Old => "L",
review_protocol::CommentSide::New => "R",
};
let head = Line::from(vec![
Span::styled(
format!(" {side} {}:{} ", d.path, d.line),
Style::default()
.fg(theme.help_keys_fg)
.bg(theme.help_panel_bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(
d.body.lines().next().unwrap_or("").to_string(),
Style::default().fg(theme.help_desc_fg).bg(theme.help_panel_bg),
),
]);
ListItem::new(head)
})
.collect();
let list = List::new(items).highlight_style(
Style::default()
.bg(theme.list_focus_bg)
.add_modifier(Modifier::BOLD),
);
let mut state = ListState::default();
state.select(Some(dm.selected.min(review.drafts.len().saturating_sub(1))));
f.render_stateful_widget(list, layout[0], &mut state);
let hint = Line::from(vec![
Span::styled(" j/k ", Style::default().fg(theme.help_keys_fg)),
Span::styled("move ", Style::default().fg(theme.help_desc_fg)),
Span::styled(" Enter ", Style::default().fg(theme.help_keys_fg)),
Span::styled("edit ", Style::default().fg(theme.help_desc_fg)),
Span::styled(" x/Del ", Style::default().fg(theme.help_keys_fg)),
Span::styled("delete ", Style::default().fg(theme.help_desc_fg)),
Span::styled(" Esc ", Style::default().fg(theme.help_keys_fg)),
Span::styled("close", Style::default().fg(theme.help_desc_fg)),
]);
f.render_widget(
Paragraph::new(hint).style(Style::default().bg(theme.help_panel_bg)),
layout[1],
);
}
Modal::Submit(m) => {
let block = Block::default()
.borders(Borders::ALL)
.title(format!(
" Submit review · {} draft{} ",
review.drafts.len(),
if review.drafts.len() == 1 { "" } else { "s" }
))
.border_style(Style::default().fg(theme.help_border_fg))
.style(Style::default().bg(theme.help_panel_bg));
let inner = block.inner(r);
f.render_widget(block, r);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ])
.split(inner);
let verdicts = [
(review_protocol::ReviewVerdict::Comment, "Comment"),
(review_protocol::ReviewVerdict::Approve, "Approve"),
(review_protocol::ReviewVerdict::RequestChanges, "Request changes"),
];
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(" ", Style::default().bg(theme.help_panel_bg)));
for (v, label) in &verdicts {
let selected = m.verdict == *v;
let prefix = if selected { "▎" } else { " " };
let style = if selected {
Style::default()
.fg(theme.help_keys_fg)
.bg(theme.help_panel_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.help_desc_fg).bg(theme.help_panel_bg)
};
spans.push(Span::styled(format!("{prefix} {label} "), style));
}
f.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(theme.help_panel_bg)),
layout[0],
);
f.render_widget(&m.textarea, layout[2]);
let focus = match m.focus {
crate::review_session::SubmitFocus::Verdict => "verdict",
crate::review_session::SubmitFocus::Body => "body",
};
let hint = Line::from(vec![
Span::styled(format!(" focus: {focus} "), Style::default().fg(theme.help_desc_fg)),
Span::styled(" Tab ", Style::default().fg(theme.help_keys_fg)),
Span::styled("switch ", Style::default().fg(theme.help_desc_fg)),
Span::styled(" a/c/r ", Style::default().fg(theme.help_keys_fg)),
Span::styled("verdict ", Style::default().fg(theme.help_desc_fg)),
Span::styled(" Esc ", Style::default().fg(theme.help_keys_fg)),
Span::styled("cancel ", Style::default().fg(theme.help_desc_fg)),
Span::styled(" Ctrl-S ", Style::default().fg(theme.help_keys_fg)),
Span::styled("submit", Style::default().fg(theme.help_desc_fg)),
]);
f.render_widget(
Paragraph::new(hint).style(Style::default().bg(theme.help_panel_bg)),
layout[3],
);
}
}
}
fn render_title(f: &mut Frame, area: Rect, app: &App) {
let theme = &app.config.theme;
let (l, r) = app.header_label();
let (a, d, m) = app.change_summary();
let mut spans = vec![
Span::styled(
" lziff ",
Style::default()
.add_modifier(Modifier::BOLD)
.bg(theme.title_bg)
.fg(theme.title_fg),
),
Span::raw(" "),
Span::styled(l, Style::default().fg(theme.title_old)),
Span::raw(" ↔ "),
Span::styled(r, Style::default().fg(theme.title_new)),
Span::raw(" "),
Span::styled(format!("+{a}"), Style::default().fg(Color::Green)),
Span::raw(" "),
Span::styled(format!("-{d}"), Style::default().fg(Color::Red)),
Span::raw(" "),
Span::styled(format!("~{m}"), Style::default().fg(Color::Yellow)),
];
if let Some(review) = app.review.as_ref() {
spans.push(Span::raw(" "));
let n = review.drafts.len();
let style = if n > 0 {
Style::default()
.fg(theme.help_keys_fg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Rgb(120, 120, 120))
};
spans.push(Span::styled(format!("💬 {n}"), style));
}
f.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn render_files(f: &mut Frame, area: Rect, app: &App, layout: &mut LayoutCache) {
let theme = &app.config.theme;
let focused = matches!(app.focus, Focus::Files);
let title = if app.entries.is_empty() {
app.strings.title_files_panel.clone()
} else {
format!(
"{} {} ",
app.strings.title_files_panel.trim_end(),
app.entries.len()
)
};
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(border_style(theme, focused));
layout.files_inner = block.inner(area);
let items: Vec<ListItem> = app
.entries
.iter()
.map(|e| {
let color = status_color(theme, &e.status);
let mark_style = Style::default().fg(color).add_modifier(Modifier::BOLD);
let name_style = Style::default().add_modifier(Modifier::BOLD);
ListItem::new(Line::from(vec![
Span::raw(" "),
Span::styled("●", mark_style),
Span::raw(" "),
Span::styled(e.display.clone(), name_style),
]))
})
.collect();
let list = List::new(items)
.block(block)
.highlight_style(
Style::default()
.bg(if focused {
theme.list_focus_bg
} else {
theme.list_dim_bg
})
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▎");
let mut state = ListState::default();
if !app.entries.is_empty() {
state.select(Some(app.selected));
}
f.render_stateful_widget(list, area, &mut state);
}
fn render_diff(f: &mut Frame, area: Rect, app: &App, layout: &mut LayoutCache) {
let theme = &app.config.theme;
let focused = matches!(app.focus, Focus::Diff);
let (l_label, r_label) = app.header_label();
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(10),
Constraint::Length(app.config.layout.ribbon_width),
Constraint::Min(10),
])
.split(area);
let l_block = Block::default()
.borders(Borders::ALL)
.title(format!(
"{}{}",
app.strings.title_old_prefix,
short_label(&l_label)
))
.border_style(side_border_style(theme, true, focused));
let r_block = Block::default()
.borders(Borders::ALL)
.title(format!(
"{}{}",
app.strings.title_new_prefix,
short_label(&r_label)
))
.border_style(side_border_style(theme, false, focused));
let l_inner = l_block.inner(cols[0]);
let r_inner = r_block.inner(cols[2]);
let ribbon_area = Rect {
x: cols[1].x,
y: l_inner.y,
width: cols[1].width,
height: l_inner.height,
};
layout.diff_left_inner = l_inner;
layout.diff_right_inner = r_inner;
f.render_widget(l_block, cols[0]);
f.render_widget(r_block, cols[2]);
let viewport_h = l_inner.height.min(r_inner.height) as usize;
layout.viewport_h = viewport_h as u16;
if viewport_h == 0 {
return;
}
let cursor_y = app.cursor_y.min(viewport_h.saturating_sub(1));
let lineno_w_l = lineno_width(&app.diff.left);
let lineno_w_r = lineno_width(&app.diff.right);
let l_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(lineno_w_l as u16), Constraint::Min(1)])
.split(l_inner);
let r_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(lineno_w_r as u16), Constraint::Min(1)])
.split(r_inner);
let mut l_no_lines: Vec<Line> = Vec::with_capacity(viewport_h);
let mut l_text_lines: Vec<Line> = Vec::with_capacity(viewport_h);
let mut r_no_lines: Vec<Line> = Vec::with_capacity(viewport_h);
let mut r_text_lines: Vec<Line> = Vec::with_capacity(viewport_h);
let l_text_w = l_cols[1].width as usize;
let r_text_w = r_cols[1].width as usize;
let cur_path: &str = app
.entries
.get(app.selected)
.map(|e| e.id.as_str())
.unwrap_or("");
let has_draft = |is_left: bool, line_no: usize| -> bool {
let Some(review) = app.review.as_ref() else {
return false;
};
let side = if is_left {
review_protocol::CommentSide::Old
} else {
review_protocol::CommentSide::New
};
review.has_draft_at(cur_path, line_no as u32, side)
};
for y in 0..viewport_h {
let li_render = app.left_top + y;
let ri_render = app.right_top + y;
let l_line = app
.diff
.left_render
.get(li_render)
.and_then(|opt| opt.as_ref().and_then(|&i| app.diff.left.get(i)));
let r_line = app
.diff
.right_render
.get(ri_render)
.and_then(|opt| opt.as_ref().and_then(|&i| app.diff.right.get(i)));
let is_alignment = y == cursor_y;
let l_draft = l_line.map_or(false, |l| has_draft(true, l.line_no));
let r_draft = r_line.map_or(false, |l| has_draft(false, l.line_no));
let (lno, ltxt) = render_pane_row(
theme, l_line, true, is_alignment, l_text_w, lineno_w_l, l_draft,
);
let (rno, rtxt) = render_pane_row(
theme, r_line, false, is_alignment, r_text_w, lineno_w_r, r_draft,
);
l_no_lines.push(lno);
l_text_lines.push(ltxt);
r_no_lines.push(rno);
r_text_lines.push(rtxt);
}
let ribbon_lines = build_ribbon_grid(app, viewport_h, cursor_y);
f.render_widget(Paragraph::new(l_no_lines), l_cols[0]);
f.render_widget(Paragraph::new(l_text_lines), l_cols[1]);
f.render_widget(Paragraph::new(ribbon_lines), ribbon_area);
f.render_widget(Paragraph::new(r_no_lines), r_cols[0]);
f.render_widget(Paragraph::new(r_text_lines), r_cols[1]);
}
fn build_ribbon_grid(app: &App, viewport_h: usize, cursor_y: usize) -> Vec<Line<'static>> {
let theme = &app.config.theme;
const COLS_N: usize = 5;
let mut grid: Vec<Vec<(char, Color, Color)>> =
vec![vec![(' ', Color::Reset, Color::Reset); COLS_N]; viewport_h];
for (y, row) in grid.iter_mut().enumerate().take(viewport_h) {
let l_change = matches!(
app.diff.left_render.get(app.left_top + y),
Some(Some(idx)) if app.diff.left.get(*idx)
.is_some_and(|l| matches!(l.kind, LineKind::Modified | LineKind::Standalone))
);
let r_change = matches!(
app.diff.right_render.get(app.right_top + y),
Some(Some(idx)) if app.diff.right.get(*idx)
.is_some_and(|l| matches!(l.kind, LineKind::Modified | LineKind::Standalone))
);
let l_phantom = matches!(app.diff.left_render.get(app.left_top + y), Some(None));
let r_phantom = matches!(app.diff.right_render.get(app.right_top + y), Some(None));
if l_change || l_phantom {
row[0].2 = theme.track_bg_left;
}
if r_change || r_phantom {
row[4].2 = theme.track_bg_right;
}
}
let in_view = |y: isize| y >= 0 && y < viewport_h as isize;
let mut paths: Vec<PathPlan> = Vec::new();
for (idx, seg) in app.diff.segments.iter().enumerate() {
let yl = seg.l_render_start as isize - app.left_top as isize;
let yr = seg.r_render_start as isize - app.right_top as isize;
if !in_view(yl) && !in_view(yr) {
continue;
}
paths.push(PathPlan {
seg_idx: idx,
yl,
yr,
line_visible: true,
});
}
let active = active_segment_idx(app);
resolve_path_conflicts(&mut paths, viewport_h, cursor_y, active);
for plan in &paths {
let seg = &app.diff.segments[plan.seg_idx];
let yl = plan.yl;
let yr = plan.yr;
let same_row = yl == yr && in_view(yl);
let l_filled = yl == cursor_y as isize || same_row;
let r_filled = yr == cursor_y as isize || same_row;
let l_glyph = if l_filled { '●' } else { '○' };
let r_glyph = if r_filled { '●' } else { '○' };
let (l_dim, l_bright, r_dim, r_bright) = match classify_segment(seg) {
RibbonSegKind::Equal => (
theme.anchor_soft,
theme.anchor_bright,
theme.anchor_soft,
theme.anchor_bright,
),
RibbonSegKind::Delete => (
theme.change_anchor_dim_left,
theme.change_anchor_bright_left,
theme.change_anchor_dim_left,
theme.change_anchor_bright_left,
),
RibbonSegKind::Insert => (
theme.change_anchor_dim_right,
theme.change_anchor_bright_right,
theme.change_anchor_dim_right,
theme.change_anchor_bright_right,
),
RibbonSegKind::Modify => (
theme.modify_anchor_dim,
theme.modify_anchor_bright,
theme.modify_anchor_dim,
theme.modify_anchor_bright,
),
};
let l_color = if l_filled { l_bright } else { l_dim };
let r_color = if r_filled { r_bright } else { r_dim };
let line_color = match classify_segment(seg) {
RibbonSegKind::Equal => {
if l_filled || r_filled {
theme.line_bright
} else {
theme.line_dim
}
}
RibbonSegKind::Delete => {
if l_filled || r_filled {
theme.change_anchor_bright_left
} else {
theme.change_anchor_dim_left
}
}
RibbonSegKind::Insert => {
if l_filled || r_filled {
theme.change_anchor_bright_right
} else {
theme.change_anchor_dim_right
}
}
RibbonSegKind::Modify => {
if l_filled || r_filled {
theme.change_line_bright
} else {
theme.change_line_dim
}
}
};
if in_view(yl) {
let cell = &mut grid[yl as usize][0];
cell.0 = l_glyph;
cell.1 = l_color;
}
if in_view(yr) {
let cell = &mut grid[yr as usize][4];
cell.0 = r_glyph;
cell.1 = r_color;
}
if !plan.line_visible {
continue;
}
if yl == yr {
if in_view(yl) {
let y = yl as usize;
grid[y][1].0 = '─';
grid[y][1].1 = line_color;
grid[y][2].0 = '─';
grid[y][2].1 = line_color;
grid[y][3].0 = '─';
grid[y][3].1 = line_color;
}
} else if yl < yr {
if in_view(yl) {
let y = yl as usize;
grid[y][1].0 = '─';
grid[y][1].1 = line_color;
grid[y][2].0 = '┐';
grid[y][2].1 = line_color;
}
let from = (yl + 1).max(0);
let to = yr.min(viewport_h as isize);
for y in from..to {
grid[y as usize][2].0 = '│';
grid[y as usize][2].1 = line_color;
}
if in_view(yr) {
let y = yr as usize;
grid[y][2].0 = '└';
grid[y][2].1 = line_color;
grid[y][3].0 = '─';
grid[y][3].1 = line_color;
}
} else {
if in_view(yr) {
let y = yr as usize;
grid[y][3].0 = '─';
grid[y][3].1 = line_color;
grid[y][2].0 = '┌';
grid[y][2].1 = line_color;
}
let from = (yr + 1).max(0);
let to = yl.min(viewport_h as isize);
for y in from..to {
grid[y as usize][2].0 = '│';
grid[y as usize][2].1 = line_color;
}
if in_view(yl) {
let y = yl as usize;
grid[y][2].0 = '┘';
grid[y][2].1 = line_color;
grid[y][1].0 = '─';
grid[y][1].1 = line_color;
}
}
}
let mut lines = Vec::with_capacity(viewport_h);
for (y, row) in grid.iter().enumerate() {
let on_align = y == cursor_y;
let spans: Vec<Span> = row
.iter()
.map(|&(c, fg, bg)| {
let final_bg = if on_align {
overlay_alignment(bg, theme)
} else {
bg
};
Span::styled(c.to_string(), Style::default().fg(fg).bg(final_bg))
})
.collect();
lines.push(Line::from(spans));
}
lines
}
fn overlay_alignment(base: Color, theme: &Theme) -> Color {
match base {
Color::Reset => theme.alignment_overlay,
Color::Rgb(r, g, b) => Color::Rgb(
r.saturating_add(20),
g.saturating_add(25),
b.saturating_add(40),
),
c => c,
}
}
#[derive(Debug, Clone, Copy)]
enum RibbonSegKind {
Equal,
Delete,
Insert,
Modify,
}
fn classify_segment(seg: &AlignSegment) -> RibbonSegKind {
if !seg.is_change {
return RibbonSegKind::Equal;
}
match (seg.l_count, seg.r_count) {
(0, _) => RibbonSegKind::Insert,
(_, 0) => RibbonSegKind::Delete,
_ => RibbonSegKind::Modify,
}
}
#[derive(Debug)]
struct PathPlan {
seg_idx: usize,
yl: isize,
yr: isize,
line_visible: bool,
}
fn active_segment_idx(app: &App) -> Option<usize> {
let row = match app.anchor_side {
AnchorSide::Left => app.left_top + app.cursor_y,
AnchorSide::Right => app.right_top + app.cursor_y,
};
for (i, seg) in app.diff.segments.iter().enumerate() {
let (start, end) = match app.anchor_side {
AnchorSide::Left => (
seg.l_render_start,
seg.l_render_start + seg.l_render_count,
),
AnchorSide::Right => (
seg.r_render_start,
seg.r_render_start + seg.r_render_count,
),
};
if row >= start && row < end {
return Some(i);
}
}
None
}
fn resolve_path_conflicts(
paths: &mut [PathPlan],
viewport_h: usize,
cursor_y: usize,
active: Option<usize>,
) {
let n = paths.len();
loop {
let mut row_to_paths: Vec<Vec<usize>> = vec![Vec::new(); viewport_h];
for (i, p) in paths.iter().enumerate() {
if !p.line_visible {
continue;
}
let (span_min, span_max) = if p.yl == p.yr {
(p.yl, p.yr)
} else {
(p.yl.min(p.yr), p.yl.max(p.yr))
};
for row in span_min..=span_max {
if row >= 0 && (row as usize) < viewport_h {
row_to_paths[row as usize].push(i);
}
}
}
let mut conflict_count = vec![0usize; n];
let mut any_conflict = false;
for paths_at in &row_to_paths {
if paths_at.len() > 1 {
any_conflict = true;
for &p in paths_at {
conflict_count[p] += 1;
}
}
}
if !any_conflict {
return;
}
let cy = cursor_y as isize;
let dist = |p: &PathPlan| -> isize {
let dl = (p.yl - cy).abs();
let dr = (p.yr - cy).abs();
dl.min(dr)
};
let mut target: Option<usize> = None;
for i in 0..n {
if !paths[i].line_visible {
continue;
}
if Some(paths[i].seg_idx) == active {
continue;
}
if conflict_count[i] == 0 {
continue;
}
target = Some(match target {
None => i,
Some(j) => {
let key_i = (conflict_count[i], dist(&paths[i]), paths[i].seg_idx);
let key_j = (conflict_count[j], dist(&paths[j]), paths[j].seg_idx);
if key_i > key_j {
i
} else {
j
}
}
});
}
match target {
Some(p) => paths[p].line_visible = false,
None => return,
}
}
}
fn render_pane_row(
theme: &Theme,
line: Option<&PaneLine>,
is_left: bool,
is_alignment: bool,
text_w: usize,
lineno_w: usize,
has_draft: bool,
) -> (Line<'static>, Line<'static>) {
let Some(line) = line else {
let bg = if is_alignment {
theme.alignment_overlay
} else {
Color::Reset
};
let blank = Span::styled(" ".repeat(lineno_w + text_w), Style::default().bg(bg));
return (Line::from(blank.clone()), Line::from(blank));
};
let bg = line_bg(theme, line.kind, is_left, is_alignment);
let no_inner_w = lineno_w.saturating_sub(2);
let no_line = if has_draft {
Line::from(vec![
Span::styled(
"✱".to_string(),
Style::default()
.fg(theme.help_keys_fg)
.bg(bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{:>width$} ", line.line_no, width = no_inner_w),
Style::default().fg(theme.fg_gutter).bg(bg),
),
])
} else {
Line::from(Span::styled(
format!(" {:>width$} ", line.line_no, width = no_inner_w),
Style::default().fg(theme.fg_gutter).bg(bg),
))
};
let mut spans: Vec<Span> = Vec::new();
let mut used = 0usize;
for seg in &line.segments {
let style = segment_style(theme, line.kind, seg, is_left, bg);
let text = expand_render_text(&seg.text, used, text_w.saturating_sub(used));
used += text.chars().count();
spans.push(Span::styled(text, style));
if used >= text_w {
break;
}
}
if used < text_w {
spans.push(Span::styled(
" ".repeat(text_w - used),
Style::default().bg(bg),
));
}
(no_line, Line::from(spans))
}
const TAB_WIDTH: usize = 4;
fn expand_render_text(text: &str, start_col: usize, max: usize) -> String {
if max == 0 {
return String::new();
}
let mut out = String::with_capacity(text.len());
let mut col = start_col;
let mut written = 0usize;
for ch in text.chars() {
if written >= max {
break;
}
if ch == '\t' {
let next_stop = ((col / TAB_WIDTH) + 1) * TAB_WIDTH;
let spaces = next_stop - col;
for _ in 0..spaces {
if written >= max {
break;
}
out.push(' ');
col += 1;
written += 1;
}
} else if ch.is_control() {
out.push(' ');
col += 1;
written += 1;
} else {
out.push(ch);
col += 1;
written += 1;
}
}
out
}
fn segment_style(theme: &Theme, kind: LineKind, seg: &Segment, is_left: bool, row_bg: Color) -> Style {
let mut s = Style::default().bg(row_bg);
s = match (kind, seg.kind) {
(LineKind::Modified, SegmentKind::Changed) => {
if is_left {
s.bg(theme.hl_delete).fg(Color::White)
} else {
s.bg(theme.hl_insert).fg(Color::White)
}
}
(LineKind::Standalone, _) => {
if is_left {
s.fg(theme.fg_standalone_left)
} else {
s.fg(theme.fg_standalone_right)
}
}
_ => s,
};
s
}
fn line_bg(theme: &Theme, kind: LineKind, is_left: bool, is_alignment: bool) -> Color {
let base = match kind {
LineKind::Equal => Color::Reset,
LineKind::Standalone => {
if is_left {
theme.bg_delete
} else {
theme.bg_insert
}
}
LineKind::Modified => {
if is_left {
theme.bg_mod_left
} else {
theme.bg_mod_right
}
}
};
if is_alignment {
match base {
Color::Reset => theme.alignment_overlay,
Color::Rgb(r, g, b) => Color::Rgb(
r.saturating_add(15),
g.saturating_add(20),
b.saturating_add(30),
),
c => c,
}
} else {
base
}
}
fn lineno_width(lines: &[PaneLine]) -> usize {
let max = lines.iter().map(|l| l.line_no).max().unwrap_or(1);
max.to_string().len() + 2
}
fn border_style(theme: &Theme, focused: bool) -> Style {
if focused {
Style::default().fg(theme.diff_focus)
} else {
Style::default().fg(theme.diff_dim)
}
}
fn side_border_style(theme: &Theme, is_left: bool, focused: bool) -> Style {
let color = match (is_left, focused) {
(true, true) => theme.border_left_focus,
(true, false) => theme.border_left_dim,
(false, true) => theme.border_right_focus,
(false, false) => theme.border_right_dim,
};
Style::default().fg(color)
}
fn status_color(theme: &Theme, status: &str) -> Color {
let trimmed = status.trim();
if status.contains('U') || matches!(trimmed, "AA" | "DD") {
return theme.status_conflict_fg;
}
let c = status.chars().next().unwrap_or(' ');
match c {
'M' => Color::Yellow,
'A' => Color::Green,
'D' => Color::Red,
'R' | 'C' => Color::Cyan,
'?' => Color::Magenta,
_ => Color::Gray,
}
}
fn short_label(s: &str) -> String {
let max = 32;
if s.chars().count() <= max {
s.to_string()
} else {
let tail: String = s.chars().rev().take(max - 1).collect::<Vec<_>>().into_iter().rev().collect();
format!("…{}", tail)
}
}
fn render_status(f: &mut Frame, area: Rect, app: &App) {
let hint = if app.show_help {
app.strings.status_hint_help_open.clone()
} else {
app.strings.status_hint_default.clone()
};
let line = Line::from(vec![
Span::styled(
format!(" {} ", hint),
Style::default().fg(Color::Rgb(180, 180, 180)),
),
Span::raw(" "),
Span::styled(
app.status.clone(),
Style::default().fg(Color::Rgb(140, 200, 140)),
),
]);
f.render_widget(Paragraph::new(line), area);
}
fn render_help(f: &mut Frame, area: Rect, app: &App) {
let theme = &app.config.theme;
let mut all_sections: Vec<&crate::i18n::HelpSection> =
app.strings.help_sections.iter().collect();
if app.review.is_some() {
all_sections.extend(app.strings.help_review_sections.iter());
}
let sections: &[&crate::i18n::HelpSection] = &all_sections;
if sections.is_empty() {
return;
}
let keys_w = sections
.iter()
.flat_map(|s| s.entries.iter().map(|e| e.keys.chars().count()))
.max()
.unwrap_or(8);
let desc_w = sections
.iter()
.flat_map(|s| s.entries.iter().map(|e| e.desc.chars().count()))
.max()
.unwrap_or(20);
let title_w = sections
.iter()
.map(|s| s.title.chars().count())
.max()
.unwrap_or(0);
let entry_w = keys_w + 4 + desc_w;
let body_w = entry_w.max(title_w) + 4;
let w = (body_w as u16)
.clamp(36, 96)
.min(area.width.saturating_sub(4));
let mut body_h = 1usize; for (i, s) in sections.iter().enumerate() {
if i > 0 {
body_h += 1; }
body_h += 1; body_h += s.entries.len();
}
body_h += 1; let h = (body_h as u16 + 2).min(area.height.saturating_sub(4));
let x = area.x + (area.width - w) / 2;
let y = area.y + (area.height - h) / 2;
let r = Rect::new(x, y, w, h);
f.render_widget(Clear, r);
let bg = theme.help_panel_bg;
let mut lines: Vec<Line> = Vec::with_capacity(body_h);
lines.push(Line::raw(""));
for (i, sec) in sections.iter().enumerate() {
if i > 0 {
lines.push(Line::from(Span::styled("", Style::default().bg(bg))));
}
lines.push(Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled(
sec.title.clone(),
Style::default()
.fg(theme.help_section_fg)
.bg(bg)
.add_modifier(Modifier::BOLD),
),
]));
for entry in &sec.entries {
let pad = keys_w.saturating_sub(entry.keys.chars().count());
let pad_str = " ".repeat(pad);
lines.push(Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled(pad_str, Style::default().bg(bg)),
Span::styled(
entry.keys.clone(),
Style::default()
.fg(theme.help_keys_fg)
.bg(bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ", Style::default().bg(bg)),
Span::styled(
entry.desc.clone(),
Style::default().fg(theme.help_desc_fg).bg(bg),
),
]));
}
}
lines.push(Line::raw(""));
let block = Block::default()
.borders(Borders::ALL)
.title(app.strings.title_help.clone())
.style(Style::default().bg(bg))
.title_style(
Style::default()
.fg(theme.help_section_fg)
.bg(bg)
.add_modifier(Modifier::BOLD),
)
.border_style(Style::default().fg(theme.help_border_fg).bg(bg));
let p = Paragraph::new(lines).block(block);
f.render_widget(p, r);
}
pub fn scrub_control_chars(buf: &mut Buffer) {
for cell in buf.content.iter_mut() {
if cell.symbol().chars().any(char::is_control) {
cell.set_symbol(" ");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scrub_control_chars_blanks_escape_sequences() {
let mut buf = Buffer::empty(Rect::new(0, 0, 16, 1));
let line = Line::from(Span::raw("a\x1b]52;c;eA==\x07b"));
ratatui::widgets::Widget::render(line, buf.area, &mut buf);
assert!(buf.content.iter().any(|c| c.symbol().contains('\x1b')));
scrub_control_chars(&mut buf);
assert!(buf
.content
.iter()
.all(|c| !c.symbol().chars().any(char::is_control)));
assert_eq!(buf.content[0].symbol(), "a");
}
}