use std::collections::{HashMap, HashSet};
use unicode_width::UnicodeWidthStr;
use ratatui::{
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
use crate::app::{App, DisplayableItem, FrameContext, SearchState, Selection};
use crate::diff::{DiffLine, LineSource};
use crate::image_diff::{ImageCache, IMAGE_PANEL_OVERHEAD};
use crate::syntax::reset_highlight_state;
use crate::vcs::VcsBackend;
use super::colors::{line_style, status_symbol, SEARCH_CURRENT_BG, SEARCH_MATCH_BG};
use super::selection::{apply_selection_to_span, get_line_selection_range};
use super::colors::line_style_with_highlight;
use super::spans::{
build_deletion_spans_with_highlight, build_insertion_spans_with_highlight, classify_inline_change,
get_deletion_source, get_insertion_source, inline_display_width, InlineChangeType,
syntax_highlight_content, syntax_highlight_inline_spans,
};
use super::wrapping::wrap_content;
use super::{ScreenRowInfo, PREFIX_CHAR_WIDTH};
pub struct DiffViewModel<'a> {
pub items: &'a [DisplayableItem],
pub lines: &'a [DiffLine],
pub selection: &'a Option<Selection>,
pub collapsed_files: &'a HashSet<String>,
pub area: Rect,
pub show_copied_flash: bool,
pub image_cache: &'a ImageCache,
pub font_size: (u16, u16),
pub vcs_backend: VcsBackend,
pub search: &'a Option<SearchState>,
pub upstream_files: Option<&'a HashSet<String>>,
pub reviewed_files: &'a HashMap<String, u64>,
}
#[derive(Debug, Clone)]
pub struct ImageRenderPosition {
pub file_path: String,
pub start_row: u16,
pub height: u16,
pub expected_available_height: u16,
}
pub struct RenderOutput {
pub row_map: Vec<ScreenRowInfo>,
pub content_offset: (u16, u16),
pub line_num_width: usize,
pub content_width: usize,
pub image_positions: Vec<ImageRenderPosition>,
}
impl<'a> DiffViewModel<'a> {
pub fn from_app(app: &'a App, ctx: &'a FrameContext, area: Rect) -> Self {
let (start, end) = ctx.visible_range(app);
let items = &ctx.items()[start..end];
Self {
items,
lines: &app.lines,
selection: &app.view.selection,
collapsed_files: &app.view.collapsed_files,
area,
show_copied_flash: app.should_show_copied_flash(),
image_cache: &app.image_cache,
font_size: app.font_size,
vcs_backend: app.comparison.vcs_backend,
search: &app.search,
upstream_files: app.comparison.divergence.as_ref().map(|d| &d.upstream_files),
reviewed_files: &app.view.reviewed_files,
}
}
fn is_file_collapsed(&self, path: &str) -> bool {
self.collapsed_files.contains(path)
}
fn line_prefix(&self, line: &DiffLine, default_char: char, source: LineSource) -> String {
if line.move_target.is_some() {
"M ".to_string()
} else {
format!("{} {} ", default_char, status_symbol(source, self.vcs_backend))
}
}
pub fn render(&self, frame: &mut Frame) -> RenderOutput {
reset_highlight_state();
let max_line_num = self
.items
.iter()
.filter_map(|item| {
if let DisplayableItem::Line(idx) = item {
self.lines[*idx].line_number
} else {
None
}
})
.max()
.unwrap_or(0);
let line_num_width = if max_line_num > 0 {
max_line_num.to_string().len() + 1
} else {
0
};
let available_width = self.area.width.saturating_sub(2) as usize;
let prefix_width =
if line_num_width > 0 { line_num_width + 1 } else { 0 } + PREFIX_CHAR_WIDTH;
let content_width = available_width.saturating_sub(prefix_width);
let content_offset_x = self.area.x + 1;
let content_offset_y = self.area.y + 1;
let mut all_lines: Vec<Line> = Vec::new();
let mut all_row_infos: Vec<ScreenRowInfo> = Vec::new();
let mut image_positions: Vec<ImageRenderPosition> = Vec::new();
let mut screen_row_idx = 0;
for item in self.items {
match item {
DisplayableItem::Elided(count) => {
self.render_elided_marker(
*count,
line_num_width,
&mut all_lines,
&mut all_row_infos,
);
screen_row_idx += 1;
}
DisplayableItem::Message(msg) => {
self.render_message(
msg,
line_num_width,
&mut all_lines,
&mut all_row_infos,
);
screen_row_idx += 1;
}
DisplayableItem::Line(idx) => {
let rows_added = self.render_diff_line(
&self.lines[*idx],
*idx,
line_num_width,
prefix_width,
content_width,
screen_row_idx,
&mut all_lines,
&mut all_row_infos,
&mut image_positions,
);
screen_row_idx += rows_added;
}
}
}
let current_file = self.find_current_file();
let title = if self.show_copied_flash {
Line::from(vec![Span::styled(
" ✓ Copied ",
Style::default().fg(Color::Green),
)])
} else {
match current_file {
Some(file) => Line::from(vec![Span::styled(
format!(" {} ", file),
Style::default().fg(Color::White),
)]),
None => Line::from(vec![Span::styled(
" branchdiff ",
Style::default().fg(Color::DarkGray),
)]),
}
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
frame.render_widget(Clear, self.area);
let paragraph = Paragraph::new(all_lines).block(block);
frame.render_widget(paragraph, self.area);
if let Some(search) = self.search {
let inner = Rect::new(
self.area.x + 1,
self.area.y + self.area.height.saturating_sub(2),
self.area.width.saturating_sub(2),
1,
);
if inner.width > 2 {
render_search_bar(frame, search, inner);
}
}
RenderOutput {
row_map: all_row_infos,
content_offset: (content_offset_x, content_offset_y),
line_num_width,
content_width,
image_positions,
}
}
fn find_current_file(&self) -> Option<String> {
for item in self.items {
if let DisplayableItem::Line(idx) = item {
let line = &self.lines[*idx];
if let Some(ref path) = line.file_path {
return Some(path.clone());
}
}
}
None
}
fn render_elided_marker(
&self,
count: usize,
line_num_width: usize,
all_lines: &mut Vec<Line<'static>>,
all_row_infos: &mut Vec<ScreenRowInfo>,
) {
let prefix_str = if line_num_width > 0 {
" ".repeat(line_num_width + 1)
} else {
String::new()
};
let elided_style = line_style(LineSource::Elided);
let elided_text = format!("{} lines hidden", count);
let mut spans = Vec::new();
if !prefix_str.is_empty() {
spans.push(Span::styled(prefix_str, Style::default().fg(Color::DarkGray)));
}
spans.push(Span::styled(
format!("┈┈ ⋮ {} ⋮ ┈┈", elided_text),
elided_style,
));
all_lines.push(Line::from(spans));
all_row_infos.push(ScreenRowInfo {
content: elided_text,
is_file_header: false,
file_path: None,
is_continuation: false,
});
}
fn render_message(
&self,
msg: &str,
line_num_width: usize,
all_lines: &mut Vec<Line<'static>>,
all_row_infos: &mut Vec<ScreenRowInfo>,
) {
let prefix_str = if line_num_width > 0 {
" ".repeat(line_num_width + 1)
} else {
String::new()
};
let style = line_style(LineSource::Elided);
let mut spans = Vec::new();
if !prefix_str.is_empty() {
spans.push(Span::styled(prefix_str, Style::default().fg(Color::DarkGray)));
}
spans.push(Span::styled(
format!("┈┈ ⋮ {} ⋮ ┈┈", msg),
style,
));
all_lines.push(Line::from(spans));
all_row_infos.push(ScreenRowInfo {
content: msg.to_string(),
is_file_header: false,
file_path: None,
is_continuation: false,
});
}
fn render_diff_line(
&self,
diff_line: &DiffLine,
line_idx: usize,
line_num_width: usize,
prefix_width: usize,
content_width: usize,
screen_row_idx: usize,
all_lines: &mut Vec<Line<'static>>,
all_row_infos: &mut Vec<ScreenRowInfo>,
image_positions: &mut Vec<ImageRenderPosition>,
) -> usize {
let is_moved = diff_line.move_target.is_some();
let style = if is_moved {
line_style(LineSource::CanceledCommitted)
} else {
line_style(diff_line.source)
};
let prefix_str = if let Some(num) = diff_line.line_number {
format!("{:>width$} ", num, width = line_num_width)
} else if line_num_width > 0 {
" ".repeat(line_num_width + 1)
} else {
String::new()
};
if diff_line.source == LineSource::FileHeader {
return self.render_file_header(
diff_line,
&prefix_str,
style,
all_lines,
all_row_infos,
);
}
if diff_line.source == LineSource::Elided {
return self.render_elided_line(
diff_line,
&prefix_str,
style,
all_lines,
all_row_infos,
);
}
if diff_line.is_image_marker() {
return self.render_image_marker(
diff_line,
&prefix_str,
screen_row_idx,
all_lines,
all_row_infos,
image_positions,
);
}
if !diff_line.inline_spans.is_empty() {
return self.render_inline_spans(
diff_line,
line_idx,
&prefix_str,
style,
prefix_width,
content_width,
screen_row_idx,
all_lines,
all_row_infos,
);
}
self.render_plain_content(
diff_line,
line_idx,
&prefix_str,
style,
prefix_width,
content_width,
screen_row_idx,
all_lines,
all_row_infos,
)
}
fn render_file_header(
&self,
diff_line: &DiffLine,
prefix_str: &str,
style: Style,
all_lines: &mut Vec<Line<'static>>,
all_row_infos: &mut Vec<ScreenRowInfo>,
) -> usize {
let mut spans = Vec::new();
if !prefix_str.is_empty() {
spans.push(Span::styled(
prefix_str.to_string(),
Style::default().fg(Color::DarkGray),
));
}
let is_collapsed = diff_line
.file_path
.as_ref()
.map(|p| self.is_file_collapsed(p))
.unwrap_or(false);
let chevron = if is_collapsed { "▶ " } else { "▼ " };
spans.push(Span::styled(chevron, Style::default().fg(Color::DarkGray)));
spans.push(Span::styled("── ", Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(diff_line.content.clone(), style));
spans.push(Span::styled(" ──", Style::default().fg(Color::DarkGray)));
if let Some(ref path) = diff_line.file_path
&& self.reviewed_files.contains_key(path)
{
spans.push(Span::styled(" ✓", Style::default().fg(Color::Green)));
}
if let Some(upstream) = &self.upstream_files
&& let Some(ref path) = diff_line.file_path
&& upstream.contains(path)
{
spans.push(Span::styled(" ↑", Style::default().fg(Color::Yellow)));
}
all_lines.push(Line::from(spans));
all_row_infos.push(ScreenRowInfo {
content: diff_line.content.clone(),
is_file_header: true,
file_path: diff_line.file_path.clone(),
is_continuation: false,
});
1
}
fn render_elided_line(
&self,
diff_line: &DiffLine,
prefix_str: &str,
style: Style,
all_lines: &mut Vec<Line<'static>>,
all_row_infos: &mut Vec<ScreenRowInfo>,
) -> usize {
let mut spans = Vec::new();
if !prefix_str.is_empty() {
spans.push(Span::styled(
prefix_str.to_string(),
Style::default().fg(Color::DarkGray),
));
}
spans.push(Span::styled(
format!("┈┈ ⋮ {} ⋮ ┈┈", diff_line.content),
style,
));
all_lines.push(Line::from(spans));
all_row_infos.push(ScreenRowInfo {
content: diff_line.content.clone(),
is_file_header: false,
file_path: diff_line.file_path.clone(),
is_continuation: false,
});
1
}
fn render_image_marker(
&self,
diff_line: &DiffLine,
prefix_str: &str,
screen_row_idx: usize,
all_lines: &mut Vec<Line<'static>>,
all_row_infos: &mut Vec<ScreenRowInfo>,
image_positions: &mut Vec<ImageRenderPosition>,
) -> usize {
let image_info = diff_line
.file_path
.as_ref()
.and_then(|path| self.image_cache.peek(path));
let image_dims = image_info.map(|state| {
let before_dims = state
.before
.as_ref()
.map(|img| (img.original_width, img.original_height));
let after_dims = state
.after
.as_ref()
.map(|img| (img.original_width, img.original_height));
(before_dims, after_dims)
});
let has_image_data = image_dims.is_some_and(|(b, a)| b.is_some() || a.is_some());
if has_image_data
&& let Some(ref path) = diff_line.file_path
&& let Some((before_dims, after_dims)) = image_dims
{
let image_height = crate::ui::image_view::calculate_image_height_for_images(
before_dims,
after_dims,
self.area.width,
self.font_size,
);
let expected_available_height = image_height.saturating_sub(IMAGE_PANEL_OVERHEAD);
image_positions.push(ImageRenderPosition {
file_path: path.clone(),
start_row: screen_row_idx.min(u16::MAX as usize) as u16,
height: image_height,
expected_available_height,
});
for i in 0..image_height {
all_lines.push(Line::from(vec![]));
all_row_infos.push(ScreenRowInfo {
content: String::new(),
is_file_header: false,
file_path: diff_line.file_path.clone(),
is_continuation: i > 0,
});
}
return image_height as usize;
}
let mut spans = Vec::new();
if !prefix_str.is_empty() {
spans.push(Span::styled(
prefix_str.to_string(),
Style::default().fg(Color::DarkGray),
));
}
let display_text = match image_info {
Some(state) => {
let before_info = state
.before
.as_ref()
.map(|img| img.metadata_string())
.unwrap_or_else(|| "(new)".to_string());
let after_info = state
.after
.as_ref()
.map(|img| img.metadata_string())
.unwrap_or_else(|| "(deleted)".to_string());
format!("[image: {} -> {}]", before_info, after_info)
}
None => "[image file - loading...]".to_string(),
};
spans.push(Span::styled(" ", Style::default()));
spans.push(Span::styled(display_text, Style::default().fg(Color::Cyan)));
all_lines.push(Line::from(spans));
all_row_infos.push(ScreenRowInfo {
content: diff_line.content.clone(),
is_file_header: false,
file_path: diff_line.file_path.clone(),
is_continuation: false,
});
1
}
fn render_inline_spans(
&self,
diff_line: &DiffLine,
line_idx: usize,
prefix_str: &str,
style: Style,
prefix_width: usize,
content_width: usize,
mut screen_row_idx: usize,
all_lines: &mut Vec<Line<'static>>,
all_row_infos: &mut Vec<ScreenRowInfo>,
) -> usize {
let inline_width = inline_display_width(&diff_line.inline_spans);
let rows_before = all_lines.len();
let change_type = classify_inline_change(&diff_line.inline_spans);
let should_split = change_type == InlineChangeType::PureDeletion
|| (change_type == InlineChangeType::Mixed && inline_width > content_width);
if should_split {
let del_source = get_deletion_source(&diff_line.inline_spans);
let ins_source = if change_type == InlineChangeType::PureDeletion {
diff_line.change_source.unwrap_or(LineSource::Committed)
} else {
get_insertion_source(&diff_line.inline_spans)
};
let old_content = diff_line.old_content.as_deref().unwrap_or("");
let del_spans = build_deletion_spans_with_highlight(
&diff_line.inline_spans,
del_source,
old_content,
diff_line.file_path.as_deref(),
);
if !del_spans.is_empty() {
let del_style = line_style(del_source);
let del_prefix_str = if !prefix_str.is_empty() {
" ".repeat(prefix_str.len())
} else {
String::new()
};
let del_prefix_char = self.line_prefix(diff_line, '-', del_source);
let (mut del_lines, del_row_infos) = wrap_content(
del_spans,
old_content,
del_prefix_str,
del_prefix_char,
del_style,
content_width,
prefix_width,
);
apply_selection_to_wrapped_lines(
&mut del_lines,
&del_row_infos,
self.selection,
screen_row_idx,
prefix_width,
);
screen_row_idx += del_lines.len();
all_lines.extend(del_lines);
all_row_infos.extend(del_row_infos);
}
let new_content = &diff_line.content;
let ins_style = line_style(ins_source);
let ins_spans = build_insertion_spans_with_highlight(
&diff_line.inline_spans,
ins_source,
new_content,
diff_line.file_path.as_deref(),
);
let ins_spans = apply_search_to_content(ins_spans, self.search, line_idx);
let ins_prefix_char = self.line_prefix(diff_line, '+', ins_source);
let (mut ins_lines, ins_row_infos) = wrap_content(
ins_spans,
new_content,
prefix_str.to_string(),
ins_prefix_char,
ins_style,
content_width,
prefix_width,
);
apply_selection_to_wrapped_lines(
&mut ins_lines,
&ins_row_infos,
self.selection,
screen_row_idx,
prefix_width,
);
all_lines.extend(ins_lines);
all_row_infos.extend(ins_row_infos);
return all_lines.len() - rows_before;
}
if inline_width > content_width {
match change_type {
InlineChangeType::PureAddition => {
let highlight_source = get_insertion_source(&diff_line.inline_spans);
let highlight_style = line_style_with_highlight(highlight_source);
let content_spans = syntax_highlight_inline_spans(
&diff_line.inline_spans,
&diff_line.content,
diff_line.file_path.as_deref(),
style,
highlight_style,
);
let content_spans = apply_search_to_content(content_spans, self.search, line_idx);
let prefix_char = self.line_prefix(diff_line, diff_line.prefix, diff_line.source);
let (mut lines, row_infos) = wrap_content(
content_spans,
&diff_line.content,
prefix_str.to_string(),
prefix_char,
style,
content_width,
prefix_width,
);
apply_selection_to_wrapped_lines(
&mut lines,
&row_infos,
self.selection,
screen_row_idx,
prefix_width,
);
all_lines.extend(lines);
all_row_infos.extend(row_infos);
return all_lines.len() - rows_before;
}
InlineChangeType::NoChange | InlineChangeType::Mixed | InlineChangeType::PureDeletion => {}
}
}
let highlight_source = get_insertion_source(&diff_line.inline_spans);
let highlight_style = line_style_with_highlight(highlight_source);
let content_spans = syntax_highlight_inline_spans(
&diff_line.inline_spans,
&diff_line.content,
diff_line.file_path.as_deref(),
style,
highlight_style,
);
let content_spans = apply_search_to_inline_render(
content_spans,
&diff_line.inline_spans,
self.search,
line_idx,
);
let prefix_char = self.line_prefix(diff_line, diff_line.prefix, diff_line.source);
let (mut lines, row_infos) = wrap_content(
content_spans,
&diff_line.content,
prefix_str.to_string(),
prefix_char,
style,
content_width,
prefix_width,
);
apply_selection_to_wrapped_lines(
&mut lines,
&row_infos,
self.selection,
screen_row_idx,
prefix_width,
);
all_lines.extend(lines);
all_row_infos.extend(row_infos);
all_lines.len() - rows_before
}
fn render_plain_content(
&self,
diff_line: &DiffLine,
line_idx: usize,
prefix_str: &str,
style: Style,
prefix_width: usize,
content_width: usize,
screen_row_idx: usize,
all_lines: &mut Vec<Line<'static>>,
all_row_infos: &mut Vec<ScreenRowInfo>,
) -> usize {
let prefix_char = self.line_prefix(diff_line, diff_line.prefix, diff_line.source);
let content_spans = syntax_highlight_content(
&diff_line.content,
diff_line.file_path.as_deref(),
style,
);
let content_spans = apply_search_to_content(content_spans, self.search, line_idx);
let (mut lines, row_infos) = wrap_content(
content_spans,
&diff_line.content,
prefix_str.to_string(),
prefix_char,
style,
content_width,
prefix_width,
);
apply_selection_to_wrapped_lines(
&mut lines,
&row_infos,
self.selection,
screen_row_idx,
prefix_width,
);
let rows_added = lines.len();
all_lines.extend(lines);
all_row_infos.extend(row_infos);
rows_added
}
}
#[cfg(test)]
fn apply_selection_to_content(
content_spans: Vec<Span<'static>>,
selection: &Option<Selection>,
screen_row_idx: usize,
prefix_width: usize,
) -> Vec<Span<'static>> {
if let Some((sel_start, sel_end)) = get_line_selection_range(selection, screen_row_idx) {
let content_sel_start = sel_start.saturating_sub(prefix_width);
let content_sel_end = sel_end.saturating_sub(prefix_width);
let mut result = Vec::new();
let mut display_offset = 0;
for span in content_spans {
let span_width = UnicodeWidthStr::width(span.content.as_ref());
let span_with_selection =
apply_selection_to_span(span, display_offset, content_sel_start, content_sel_end);
display_offset += span_width;
result.extend(span_with_selection);
}
result
} else {
content_spans
}
}
fn apply_selection_to_wrapped_lines(
lines: &mut [Line<'static>],
row_infos: &[ScreenRowInfo],
selection: &Option<Selection>,
start_screen_row: usize,
prefix_width: usize,
) {
if selection.is_none() {
return;
}
for (i, line) in lines.iter_mut().enumerate() {
let screen_row = start_screen_row + i;
let Some((sel_start, sel_end)) = get_line_selection_range(selection, screen_row) else {
continue;
};
let content_sel_start = sel_start.saturating_sub(prefix_width);
let content_sel_end = sel_end.saturating_sub(prefix_width);
let prefix_span_count = if row_infos[i].is_continuation { 1 } else { 2 };
let all_spans: Vec<Span<'static>> = std::mem::take(&mut line.spans);
let mut result: Vec<Span<'static>> = Vec::with_capacity(all_spans.len() + 2);
let mut display_offset = 0;
for (idx, span) in all_spans.into_iter().enumerate() {
if idx < prefix_span_count {
result.push(span);
} else {
let span_width = UnicodeWidthStr::width(span.content.as_ref());
let selected = apply_selection_to_span(
span,
display_offset,
content_sel_start,
content_sel_end,
);
display_offset += span_width;
result.extend(selected);
}
}
*line = Line::from(result);
}
}
fn content_match_to_render_ranges(
inline_spans: &[crate::diff::InlineSpan],
content_start: usize,
content_end: usize,
) -> Vec<(usize, usize)> {
if content_start >= content_end {
return Vec::new();
}
let mut ranges: Vec<(usize, usize)> = Vec::new();
let mut content_pos = 0;
let mut render_pos = 0;
for span in inline_spans {
let len = span.text.chars().count();
if span.is_deletion {
render_pos += len;
continue;
}
let span_c_end = content_pos + len;
let inter_start = content_start.max(content_pos);
let inter_end = content_end.min(span_c_end);
if inter_start < inter_end {
let r_start = render_pos + (inter_start - content_pos);
let r_end = render_pos + (inter_end - content_pos);
match ranges.last_mut() {
Some(last) if last.1 == r_start => last.1 = r_end,
_ => ranges.push((r_start, r_end)),
}
}
content_pos = span_c_end;
render_pos += len;
if content_pos >= content_end {
break;
}
}
ranges
}
fn apply_search_to_content(
content_spans: Vec<Span<'static>>,
search: &Option<SearchState>,
line_idx: usize,
) -> Vec<Span<'static>> {
apply_search_with_translation(content_spans, search, line_idx, None)
}
fn apply_search_to_inline_render(
content_spans: Vec<Span<'static>>,
inline_spans: &[crate::diff::InlineSpan],
search: &Option<SearchState>,
line_idx: usize,
) -> Vec<Span<'static>> {
apply_search_with_translation(content_spans, search, line_idx, Some(inline_spans))
}
fn apply_search_with_translation(
content_spans: Vec<Span<'static>>,
search: &Option<SearchState>,
line_idx: usize,
inline_spans: Option<&[crate::diff::InlineSpan]>,
) -> Vec<Span<'static>> {
let Some(search) = search else {
return content_spans;
};
if search.matches.is_empty() {
return content_spans;
}
let mut highlights: Vec<(usize, usize, bool)> = Vec::new();
for (m_idx, m) in search.matches.iter().enumerate() {
if m.line_idx != line_idx {
continue;
}
let is_current = m_idx == search.current;
let c_start = m.char_start;
let c_end = m.char_start + m.char_len;
match inline_spans {
Some(inline) => {
for (rs, re) in content_match_to_render_ranges(inline, c_start, c_end) {
highlights.push((rs, re, is_current));
}
}
None => highlights.push((c_start, c_end, is_current)),
}
}
if highlights.is_empty() {
return content_spans;
}
highlights.sort_by_key(|h| std::cmp::Reverse(h.0));
let mut result = content_spans;
for (h_start, h_end, is_current) in highlights {
let bg = if is_current { SEARCH_CURRENT_BG } else { SEARCH_MATCH_BG };
result = overlay_range(result, h_start, h_end, bg);
}
result
}
fn overlay_range(
spans: Vec<Span<'static>>,
h_start: usize,
h_end: usize,
bg: ratatui::style::Color,
) -> Vec<Span<'static>> {
let mut new_result = Vec::with_capacity(spans.len() + 2);
let mut char_offset = 0;
for span in spans {
let span_char_len = span.content.chars().count();
let span_end = char_offset + span_char_len;
if span_end <= h_start || char_offset >= h_end {
new_result.push(span);
} else {
let base_style = span.style;
let text: Vec<char> = span.content.chars().collect();
let rel_start = h_start.saturating_sub(char_offset);
let rel_end = (h_end - char_offset).min(span_char_len);
if rel_start > 0 {
let before: String = text[..rel_start].iter().collect();
new_result.push(Span::styled(before, base_style));
}
let matched: String = text[rel_start..rel_end].iter().collect();
new_result.push(Span::styled(matched, base_style.bg(bg)));
if rel_end < span_char_len {
let after: String = text[rel_end..].iter().collect();
new_result.push(Span::styled(after, base_style));
}
}
char_offset = span_end;
}
new_result
}
fn render_search_bar(frame: &mut Frame, search: &SearchState, area: Rect) {
let bar_bg = Color::Rgb(40, 42, 54);
let bar_style = Style::default().fg(Color::White).bg(bar_bg);
let counter = if search.matches.is_empty() && !search.query.is_empty() {
"[no matches]".to_string()
} else if !search.matches.is_empty() {
let total = search.match_count();
if search.visible_count < total {
format!("[{}/{}]", search.current_display(), search.visible_count)
} else {
format!("[{}/{}]", search.current_display(), total)
}
} else {
String::new()
};
let counter_width = counter.len();
let available_for_query = (area.width as usize).saturating_sub(counter_width + 2);
let query_char_count = search.query.chars().count();
let display_query: String = if query_char_count > available_for_query {
let skip = query_char_count - available_for_query + 1;
format!("…{}", search.query.chars().skip(skip).collect::<String>())
} else {
search.query.clone()
};
let cursor_char = if search.input_active { "█" } else { "" };
let left_text = format!("/{}{}", display_query, cursor_char);
let padding = (area.width as usize).saturating_sub(left_text.chars().count() + counter_width);
let line = Line::from(vec![
Span::styled(left_text, bar_style),
Span::styled(" ".repeat(padding), bar_style),
Span::styled(counter, Style::default().fg(Color::DarkGray).bg(bar_bg)),
]);
frame.render_widget(Clear, area);
frame.render_widget(Paragraph::new(vec![line]), area);
}
pub fn draw_diff_view_with_frame(
frame: &mut Frame,
app: &mut App,
area: Rect,
ctx: &FrameContext,
) {
let view_model = DiffViewModel::from_app(app, ctx, area);
let output = view_model.render(frame);
app.set_content_layout(
output.content_offset.0,
output.content_offset.1,
output.line_num_width,
output.content_width,
area.width,
);
app.set_row_map(output.row_map.clone());
if !output.image_positions.is_empty() {
render_images_at_positions(
frame,
&mut app.image_cache,
app.image_picker.as_ref(),
&output.image_positions,
output.content_offset,
area,
app.font_size,
);
}
}
fn render_images_at_positions(
frame: &mut Frame,
image_cache: &mut crate::image_diff::ImageCache,
picker: Option<&ratatui_image::picker::Picker>,
positions: &[ImageRenderPosition],
content_offset: (u16, u16),
area: Rect,
font_size: (u16, u16),
) {
use crate::ui::image_view::render_image_diff;
for pos in positions {
let image_y = content_offset.1 + pos.start_row;
let viewport_bottom = area.y + area.height;
if image_y >= viewport_bottom {
continue;
}
let available_height = viewport_bottom.saturating_sub(image_y);
let clamped_height = pos.height.min(available_height);
if clamped_height == 0 {
continue;
}
let image_area = Rect::new(
area.x + 1, image_y,
area.width.saturating_sub(2), clamped_height,
);
if let Some(state) = image_cache.get_mut(&pos.file_path) {
if let Some(picker) = picker {
if let Some(ref mut before) = state.before {
before.ensure_protocol(picker);
}
if let Some(ref mut after) = state.after {
after.ensure_protocol(picker);
}
}
render_image_diff(
frame,
image_area,
state,
&pos.file_path,
pos.expected_available_height,
font_size,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::search::SearchMatch;
use crate::test_support::{base_line, change_line, TestAppBuilder};
#[test]
fn test_diff_view_model_from_app() {
let app = TestAppBuilder::new()
.with_lines(vec![DiffLine::file_header("test.rs"), base_line("content")])
.build();
let ctx = FrameContext::new(&app);
let area = Rect::new(0, 0, 80, 24);
let view_model = DiffViewModel::from_app(&app, &ctx, area);
assert_eq!(view_model.items.len(), 2);
assert_eq!(view_model.lines.len(), 2);
assert!(view_model.selection.is_none());
assert!(view_model.collapsed_files.is_empty());
}
#[test]
fn test_diff_view_model_find_current_file() {
let lines = vec![
DiffLine::file_header("test.rs"),
base_line("line1"),
change_line("line2"),
];
let app = TestAppBuilder::new().with_lines(lines).build();
let ctx = FrameContext::new(&app);
let area = Rect::new(0, 0, 80, 24);
let view_model = DiffViewModel::from_app(&app, &ctx, area);
let current_file = view_model.find_current_file();
assert_eq!(current_file, Some("test.rs".to_string()));
}
#[test]
fn test_diff_view_model_find_current_file_when_header_scrolled_above() {
let mut lines = vec![DiffLine::file_header("first.rs")];
for i in 0..5 {
let mut line = base_line(&format!("line{}", i));
line.file_path = Some("first.rs".to_string());
lines.push(line);
}
lines.push(DiffLine::file_header("second.rs"));
let mut line = base_line("second file content");
line.file_path = Some("second.rs".to_string());
lines.push(line);
let mut app = TestAppBuilder::new()
.with_lines(lines)
.with_viewport_height(4)
.build();
app.view.scroll_offset = 2;
let ctx = FrameContext::new(&app);
let area = Rect::new(0, 0, 80, 24);
let view_model = DiffViewModel::from_app(&app, &ctx, area);
let current_file = view_model.find_current_file();
assert_eq!(current_file, Some("first.rs".to_string()));
}
#[test]
fn test_diff_view_model_is_file_collapsed() {
let mut app = TestAppBuilder::new()
.with_lines(vec![DiffLine::file_header("test.rs")])
.build();
app.view.collapsed_files.insert("test.rs".to_string());
let ctx = FrameContext::new(&app);
let area = Rect::new(0, 0, 80, 24);
let view_model = DiffViewModel::from_app(&app, &ctx, area);
assert!(view_model.is_file_collapsed("test.rs"));
assert!(!view_model.is_file_collapsed("other.rs"));
}
#[test]
fn test_diff_view_model_with_selection() {
use crate::app::Position;
let mut app = TestAppBuilder::new()
.with_lines(vec![base_line("selectable content")])
.build();
app.view.selection = Some(Selection {
start: Position { row: 0, col: 5 },
end: Position { row: 0, col: 15 },
active: false,
});
let ctx = FrameContext::new(&app);
let area = Rect::new(0, 0, 80, 24);
let view_model = DiffViewModel::from_app(&app, &ctx, area);
assert!(view_model.selection.is_some());
}
#[test]
fn test_diff_view_model_show_copied_flash() {
let mut app = TestAppBuilder::new()
.with_lines(vec![DiffLine::file_header("test.rs")])
.build();
let ctx = FrameContext::new(&app);
let area = Rect::new(0, 0, 80, 24);
let view_model = DiffViewModel::from_app(&app, &ctx, area);
assert!(!view_model.show_copied_flash);
app.view.path_copied_at = Some(std::time::Instant::now());
let ctx = FrameContext::new(&app);
let view_model = DiffViewModel::from_app(&app, &ctx, area);
assert!(view_model.show_copied_flash);
app.view.path_copied_at = Some(std::time::Instant::now() - std::time::Duration::from_millis(900));
let ctx = FrameContext::new(&app);
let view_model = DiffViewModel::from_app(&app, &ctx, area);
assert!(!view_model.show_copied_flash);
}
mod highlight_source_tests {
use super::*;
use crate::diff::InlineSpan;
use crate::ui::colors::line_style_with_highlight;
#[test]
fn test_modified_base_line_uses_span_source_for_highlight() {
let mut line = DiffLine::new(LineSource::Base, "prefix inserted suffix".to_string(), ' ', Some(1));
line.old_content = Some("prefix suffix".to_string());
line.change_source = Some(LineSource::Unstaged);
line.inline_spans = vec![
InlineSpan { text: "prefix ".to_string(), source: None, is_deletion: false },
InlineSpan { text: "inserted ".to_string(), source: Some(LineSource::Unstaged), is_deletion: false },
InlineSpan { text: "suffix".to_string(), source: None, is_deletion: false },
];
assert_eq!(line.source, LineSource::Base);
let highlight_source = get_insertion_source(&line.inline_spans);
assert_eq!(highlight_source, LineSource::Unstaged,
"Highlight source should be Unstaged (from spans), not Base (from line)");
let highlight_style = line_style_with_highlight(highlight_source);
assert!(matches!(highlight_style.bg, Some(Color::Rgb(130, 130, 35))),
"Highlight style should have visible yellow background");
}
#[test]
fn test_import_line_modification_highlight() {
let inline_spans = vec![
InlineSpan { text: " widgets::{Block, Borders, ".to_string(), source: None, is_deletion: false },
InlineSpan { text: "Clear, ".to_string(), source: Some(LineSource::Unstaged), is_deletion: false },
InlineSpan { text: "Paragraph},".to_string(), source: None, is_deletion: false },
];
let line_source = LineSource::Base;
let bug_highlight_style = line_style_with_highlight(line_source);
assert_eq!(bug_highlight_style.bg, Some(Color::Reset),
"Bug: using line source gives Reset background (invisible)");
let fix_highlight_source = get_insertion_source(&inline_spans);
assert_eq!(fix_highlight_source, LineSource::Unstaged);
let fix_highlight_style = line_style_with_highlight(fix_highlight_source);
assert_eq!(fix_highlight_style.bg, Some(Color::Rgb(130, 130, 35)),
"Fix: using span source gives yellow background (visible)");
}
}
fn verify_diff_area_borders(
buffer: &ratatui::buffer::Buffer,
width: u16,
diff_height: u16,
) {
assert_eq!(buffer[(0, 0)].symbol(), "┌", "Top-left corner");
assert_eq!(buffer[(width - 1, 0)].symbol(), "┐", "Top-right corner");
for y in 1..diff_height.saturating_sub(1) {
let left = buffer[(0, y)].symbol();
let right = buffer[(width - 1, y)].symbol();
assert_eq!(left, "│", "Row {} left border: expected │, got {:?}", y, left);
assert_eq!(
right, "│",
"Row {} right border: expected │, got {:?}",
y, right
);
}
if diff_height > 1 {
assert_eq!(
buffer[(0, diff_height - 1)].symbol(),
"└",
"Bottom-left corner"
);
assert_eq!(
buffer[(width - 1, diff_height - 1)].symbol(),
"┘",
"Bottom-right corner"
);
}
}
#[test]
fn test_wrapped_ascii_lines_preserve_borders() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let width: u16 = 80;
let height: u16 = 24;
let long_content = "abcdefghij".repeat(20); let mut lines = vec![DiffLine::file_header("test.swift")];
for i in 1..=15 {
let (source, prefix) = match i % 4 {
0 => (LineSource::Base, ' '),
1 => (LineSource::Committed, '+'),
2 => (LineSource::Unstaged, '+'),
_ => (LineSource::Staged, '+'),
};
let mut line = DiffLine::new(source, long_content.clone(), prefix, Some(i));
line.file_path = Some("test.swift".to_string());
lines.push(line);
}
let mut app = TestAppBuilder::new().with_lines(lines).build();
app.estimate_content_width(width);
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
{
let frame = terminal
.draw(|f| {
let ctx = FrameContext::new(&app);
crate::ui::draw_with_frame(f, &mut app, &ctx);
})
.unwrap();
let status_h = crate::ui::status_bar_height(&app, width);
let diff_h = height - status_h;
verify_diff_area_borders(frame.buffer, width, diff_h);
}
app.view.scroll_offset = 10;
{
let frame = terminal
.draw(|f| {
let ctx = FrameContext::new(&app);
crate::ui::draw_with_frame(f, &mut app, &ctx);
})
.unwrap();
let status_h = crate::ui::status_bar_height(&app, width);
let diff_h = height - status_h;
verify_diff_area_borders(frame.buffer, width, diff_h);
}
}
#[test]
fn test_wrapped_ascii_lines_preserve_borders_narrow_terminal() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let width: u16 = 40;
let height: u16 = 30;
let long_content = "the_quick_brown_fox_jumps_over_the_lazy_dog_".repeat(5);
let mut lines = vec![DiffLine::file_header("narrow.rs")];
for i in 1..=20 {
let mut line = DiffLine::new(
LineSource::Committed,
long_content.clone(),
'+',
Some(i),
);
line.file_path = Some("narrow.rs".to_string());
lines.push(line);
}
let mut app = TestAppBuilder::new().with_lines(lines).build();
app.estimate_content_width(width);
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
for scroll in [0, 5, 15, 30] {
app.view.scroll_offset = scroll;
let frame = terminal
.draw(|f| {
let ctx = FrameContext::new(&app);
crate::ui::draw_with_frame(f, &mut app, &ctx);
})
.unwrap();
let status_h = crate::ui::status_bar_height(&app, width);
let diff_h = height - status_h;
verify_diff_area_borders(frame.buffer, width, diff_h);
}
}
#[test]
fn test_diff_view_model_includes_image_cache() {
let app = TestAppBuilder::new()
.with_lines(vec![DiffLine::file_header("test.rs")])
.build();
let ctx = FrameContext::new(&app);
let area = Rect::new(0, 0, 80, 24);
let view_model = DiffViewModel::from_app(&app, &ctx, area);
assert!(view_model.image_cache.is_empty());
}
#[test]
fn test_image_marker_rendering_without_cache_data() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let lines = vec![
DiffLine::file_header("test.png"),
DiffLine::image_marker("test.png"),
];
let mut app = TestAppBuilder::new().with_lines(lines).build();
app.estimate_content_width(80);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let frame = terminal
.draw(|f| {
let ctx = FrameContext::new(&app);
crate::ui::draw_with_frame(f, &mut app, &ctx);
})
.unwrap();
let buffer_content: String = (0..frame.buffer.area.height)
.flat_map(|y| (0..frame.buffer.area.width).map(move |x| frame.buffer[(x, y)].symbol()))
.collect();
assert!(
buffer_content.contains("loading"),
"Should show 'loading...' when image not in cache"
);
}
#[test]
fn test_image_positions_populated_without_protocols() {
use crate::image_diff::{CachedImage, ImageDiffState};
use image::DynamicImage;
let lines = vec![
DiffLine::file_header("test.png"),
DiffLine::image_marker("test.png"),
];
let mut app = TestAppBuilder::new().with_lines(lines).build();
app.estimate_content_width(80);
app.view.viewport_height = 40;
let cached_image = CachedImage {
display_image: DynamicImage::new_rgb8(100, 100),
original_width: 100,
original_height: 100,
file_size: 1024,
format_name: "PNG".to_string(),
protocol: None, };
let state = ImageDiffState {
before: Some(cached_image),
after: None,
};
app.image_cache.insert("test.png".to_string(), state);
let ctx = FrameContext::new(&app);
let area = Rect::new(0, 0, 80, 40);
let view_model = DiffViewModel::from_app(&app, &ctx, area);
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(80, 40);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let output = view_model.render(f);
assert!(
!output.image_positions.is_empty(),
"image_positions should be populated when image data exists, even without protocols"
);
assert_eq!(output.image_positions[0].file_path, "test.png");
})
.unwrap();
}
#[test]
fn test_image_render_position_fields() {
use crate::image_diff::IMAGE_PANEL_OVERHEAD;
let height = 12u16;
let pos = ImageRenderPosition {
file_path: "test/image.png".to_string(),
start_row: 42,
height,
expected_available_height: height.saturating_sub(IMAGE_PANEL_OVERHEAD),
};
assert_eq!(pos.file_path, "test/image.png");
assert_eq!(pos.start_row, 42);
assert_eq!(pos.height, 12);
assert_eq!(pos.expected_available_height, 7); }
#[test]
fn test_image_render_position_large_row_saturates() {
use crate::image_diff::IMAGE_PANEL_OVERHEAD;
let height = 12u16;
let pos = ImageRenderPosition {
file_path: "large.png".to_string(),
start_row: u16::MAX,
height,
expected_available_height: height.saturating_sub(IMAGE_PANEL_OVERHEAD),
};
assert_eq!(pos.start_row, u16::MAX);
}
#[test]
fn test_render_output_includes_image_positions() {
use crate::image_diff::IMAGE_PANEL_OVERHEAD;
let height = 12u16;
let output = RenderOutput {
row_map: Vec::new(),
content_offset: (0, 0),
line_num_width: 0,
content_width: 80,
image_positions: vec![
ImageRenderPosition {
file_path: "a.png".to_string(),
start_row: 5,
height,
expected_available_height: height.saturating_sub(IMAGE_PANEL_OVERHEAD),
},
ImageRenderPosition {
file_path: "b.png".to_string(),
start_row: 20,
height,
expected_available_height: height.saturating_sub(IMAGE_PANEL_OVERHEAD),
},
],
};
assert_eq!(output.image_positions.len(), 2);
assert_eq!(output.image_positions[0].file_path, "a.png");
assert_eq!(output.image_positions[1].start_row, 20);
}
#[test]
fn test_image_clipping_calculation() {
let viewport_bottom: u16 = 100;
let image_y: u16 = 95;
let pos_height: u16 = 12;
let available_height = viewport_bottom.saturating_sub(image_y);
let clamped_height = pos_height.min(available_height);
assert_eq!(available_height, 5);
assert_eq!(clamped_height, 5);
}
#[test]
fn test_image_entirely_below_viewport_skipped() {
let viewport_bottom: u16 = 100;
let image_y: u16 = 100;
let should_skip = image_y >= viewport_bottom;
assert!(should_skip, "Image at viewport bottom should be skipped");
let image_y_below: u16 = 150;
let should_skip_below = image_y_below >= viewport_bottom;
assert!(should_skip_below, "Image below viewport should be skipped");
}
#[test]
fn test_image_clipping_zero_available_height() {
let viewport_bottom: u16 = 100;
let image_y: u16 = 100;
let pos_height: u16 = 12;
let available_height = viewport_bottom.saturating_sub(image_y);
assert_eq!(available_height, 0);
let clamped_height = pos_height.min(available_height);
assert_eq!(clamped_height, 0);
}
#[test]
fn test_wrapped_canceled_lines_preserve_borders() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let width: u16 = 80;
let height: u16 = 24;
let long_content = "canceled_content_".repeat(15);
let mut lines = vec![DiffLine::file_header("cancel.rs")];
for i in 1..=10 {
let mut line = DiffLine::new(
LineSource::CanceledCommitted,
long_content.clone(),
'±',
Some(i),
);
line.file_path = Some("cancel.rs".to_string());
lines.push(line);
}
let mut app = TestAppBuilder::new().with_lines(lines).build();
app.estimate_content_width(width);
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
let frame = terminal
.draw(|f| {
let ctx = FrameContext::new(&app);
crate::ui::draw_with_frame(f, &mut app, &ctx);
})
.unwrap();
let status_h = crate::ui::status_bar_height(&app, width);
let diff_h = height - status_h;
verify_diff_area_borders(frame.buffer, width, diff_h);
}
#[test]
fn test_partial_rendering_consistency() {
use crate::image_diff::IMAGE_PANEL_OVERHEAD;
use crate::ui::image_view::calculate_image_height_for_images;
let img_dims = Some((400u32, 300u32));
let panel_width = 100u16;
let font_size = (8u16, 16u16);
let image_height =
calculate_image_height_for_images(img_dims, None, panel_width, font_size);
let expected_available = image_height.saturating_sub(IMAGE_PANEL_OVERHEAD);
let viewport_scenarios = [
("fully visible", image_height), ("90% visible", image_height - 2), ("50% visible", image_height / 2), ("barely visible", 3u16), ("just entering", 1u16), ];
for (scenario, clamped_height) in viewport_scenarios {
let pos = ImageRenderPosition {
file_path: "test.png".to_string(),
start_row: 0,
height: image_height,
expected_available_height: expected_available,
};
assert_eq!(
pos.expected_available_height, expected_available,
"Scenario '{}': expected_available_height should be {} (from full image height {}), \
not derived from clamped_height {}",
scenario, expected_available, image_height, clamped_height
);
}
}
#[test]
fn test_fit_dimensions_consistency_across_viewports() {
use crate::image_diff::{fit_dimensions, IMAGE_PANEL_OVERHEAD};
use crate::ui::image_view::calculate_image_height_for_images;
let img_w = 800u32;
let img_h = 600u32;
let panel_width = 120u16;
let font_size = (8u16, 16u16);
let full_height = calculate_image_height_for_images(
Some((img_w, img_h)),
None,
panel_width,
font_size,
);
let expected_available = full_height.saturating_sub(IMAGE_PANEL_OVERHEAD);
let inner_width = (panel_width.saturating_sub(4)) / 2; let (expected_w, expected_h) =
fit_dimensions(img_w, img_h, inner_width, expected_available, font_size);
let clamped_heights = [
expected_available, expected_available - 5, expected_available / 2, 5u16, ];
for clamped in clamped_heights {
let (w, h) =
fit_dimensions(img_w, img_h, inner_width, expected_available, font_size);
assert_eq!(
(w, h),
(expected_w, expected_h),
"fit_dimensions with expected_available should always produce ({}, {})",
expected_w,
expected_h
);
if clamped < expected_available {
let (clamped_w, _clamped_h) =
fit_dimensions(img_w, img_h, inner_width, clamped, font_size);
assert!(
clamped_w <= expected_w,
"Clamped height {} would produce width {} <= expected width {}",
clamped,
clamped_w,
expected_w
);
}
}
}
#[test]
fn test_image_render_position_expected_height_invariant() {
use crate::image_diff::{CachedImage, ImageDiffState, IMAGE_PANEL_OVERHEAD};
use crate::ui::image_view::calculate_image_height_for_images;
use image::DynamicImage;
let mut app = TestAppBuilder::new()
.with_lines(vec![
DiffLine::file_header("test.png"),
DiffLine::image_marker("test.png"),
])
.build();
app.estimate_content_width(100);
let cached_image = CachedImage {
display_image: DynamicImage::new_rgb8(400, 300),
original_width: 400,
original_height: 300,
file_size: 50000,
format_name: "PNG".to_string(),
protocol: None,
};
app.image_cache.insert(
"test.png".to_string(),
ImageDiffState {
before: None,
after: Some(cached_image),
},
);
let image_height = calculate_image_height_for_images(
None,
Some((400, 300)),
100, app.font_size,
);
let expected_available = image_height.saturating_sub(IMAGE_PANEL_OVERHEAD);
for scroll in [0, 5, 10, 20] {
app.view.scroll_offset = scroll;
app.view.viewport_height = 30;
let ctx = FrameContext::new(&app);
let area = Rect::new(0, 0, 100, 30);
let view_model = DiffViewModel::from_app(&app, &ctx, area);
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(100, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let output = view_model.render(f);
for pos in &output.image_positions {
assert_eq!(
pos.expected_available_height, expected_available,
"At scroll_offset={}, expected_available_height should be {} \
(derived from image dimensions), got {}",
scroll, expected_available, pos.expected_available_height
);
}
})
.unwrap();
}
}
fn render_to_output(app: &App, width: u16, height: u16) -> RenderOutput {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let ctx = FrameContext::new(app);
let area = Rect::new(0, 0, width, height);
let view_model = DiffViewModel::from_app(app, &ctx, area);
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
let mut output = None;
terminal
.draw(|f| {
output = Some(view_model.render(f));
})
.unwrap();
output.unwrap()
}
fn render_with_items(app: &App, items: Vec<DisplayableItem>, width: u16, height: u16) -> RenderOutput {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let ctx = FrameContext::with_items(items, app);
let area = Rect::new(0, 0, width, height);
let view_model = DiffViewModel::from_app(app, &ctx, area);
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
let mut output = None;
terminal
.draw(|f| {
output = Some(view_model.render(f));
})
.unwrap();
output.unwrap()
}
#[test]
fn test_render_plain_content_populates_row_map() {
let mut lines = vec![DiffLine::file_header("test.rs")];
for i in 1..=3 {
let mut line = base_line(&format!("line {}", i));
line.line_number = Some(i);
line.file_path = Some("test.rs".to_string());
lines.push(line);
}
let mut app = TestAppBuilder::new().with_lines(lines).build();
app.estimate_content_width(80);
let output = render_to_output(&app, 80, 24);
assert_eq!(output.row_map.len(), 4, "1 header + 3 content lines");
assert!(output.row_map[0].is_file_header);
assert_eq!(output.row_map[0].file_path.as_deref(), Some("test.rs"));
for i in 1..=3 {
assert!(!output.row_map[i].is_file_header);
assert_eq!(output.row_map[i].content, format!("line {}", i));
assert!(!output.row_map[i].is_continuation);
}
assert!(output.line_num_width > 0);
}
#[test]
fn test_render_elided_marker_row_map() {
let app = TestAppBuilder::new()
.with_lines(vec![base_line("placeholder")])
.build();
let items = vec![DisplayableItem::Elided(42)];
let output = render_with_items(&app, items, 80, 24);
assert_eq!(output.row_map.len(), 1);
assert_eq!(output.row_map[0].content, "42 lines hidden");
assert!(!output.row_map[0].is_file_header);
assert!(!output.row_map[0].is_continuation);
}
#[test]
fn test_render_inline_spans_pure_deletion_splits_into_two_rows() {
use crate::diff::InlineSpan;
let mut line = DiffLine::new(
LineSource::Committed,
"kept text".to_string(),
'+',
Some(1),
);
line.file_path = Some("test.rs".to_string());
line.old_content = Some("deleted prefix kept text".to_string());
line.change_source = Some(LineSource::Committed);
line.inline_spans = vec![
InlineSpan { text: "deleted prefix ".to_string(), source: Some(LineSource::Committed), is_deletion: true },
InlineSpan { text: "kept text".to_string(), source: None, is_deletion: false },
];
let mut app = TestAppBuilder::new()
.with_lines(vec![DiffLine::file_header("test.rs"), line])
.build();
app.estimate_content_width(80);
let output = render_to_output(&app, 80, 24);
assert!(
output.row_map.len() >= 3,
"Expected header + del + ins rows, got {} rows",
output.row_map.len()
);
}
#[test]
fn test_render_inline_spans_fits_single_row() {
use crate::diff::InlineSpan;
let mut line = DiffLine::new(
LineSource::Committed,
"prefix inserted suffix".to_string(),
'+',
Some(1),
);
line.file_path = Some("test.rs".to_string());
line.inline_spans = vec![
InlineSpan { text: "prefix ".to_string(), source: None, is_deletion: false },
InlineSpan { text: "inserted ".to_string(), source: Some(LineSource::Committed), is_deletion: false },
InlineSpan { text: "suffix".to_string(), source: None, is_deletion: false },
];
let mut app = TestAppBuilder::new()
.with_lines(vec![DiffLine::file_header("test.rs"), line])
.build();
app.estimate_content_width(80);
let output = render_to_output(&app, 80, 24);
assert_eq!(output.row_map.len(), 2, "header + 1 content row");
assert!(!output.row_map[1].is_file_header);
assert!(!output.row_map[1].is_continuation);
}
#[test]
fn test_render_plain_content_wrapping_sets_continuation() {
let long_content = "x".repeat(200);
let mut line = base_line(&long_content);
line.line_number = Some(1);
line.file_path = Some("test.rs".to_string());
let mut app = TestAppBuilder::new()
.with_lines(vec![DiffLine::file_header("test.rs"), line])
.build();
app.estimate_content_width(40);
let output = render_to_output(&app, 40, 24);
let content_rows: Vec<_> = output.row_map.iter().skip(1).collect();
assert!(content_rows.len() > 1, "line should wrap at 40 cols");
assert!(!content_rows[0].is_continuation, "first row is not a continuation");
for row in &content_rows[1..] {
assert!(row.is_continuation, "wrapped rows should be continuations");
}
}
#[test]
fn test_render_title_shows_current_file() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut line = base_line("content");
line.file_path = Some("src/main.rs".to_string());
let mut app = TestAppBuilder::new()
.with_lines(vec![DiffLine::file_header("src/main.rs"), line])
.build();
app.estimate_content_width(80);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let frame = terminal
.draw(|f| {
let ctx = FrameContext::new(&app);
let area = Rect::new(0, 0, 80, 24);
let vm = DiffViewModel::from_app(&app, &ctx, area);
vm.render(f);
})
.unwrap();
let top_row: String = (0..80)
.map(|x| frame.buffer[(x, 0)].symbol().to_string())
.collect();
assert!(top_row.contains("src/main.rs"), "title should show current file, got: {}", top_row);
}
#[test]
fn test_render_title_shows_branchdiff_fallback() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut app = TestAppBuilder::new()
.with_lines(vec![base_line("no file path")])
.build();
app.estimate_content_width(80);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let frame = terminal
.draw(|f| {
let ctx = FrameContext::new(&app);
let area = Rect::new(0, 0, 80, 24);
let vm = DiffViewModel::from_app(&app, &ctx, area);
vm.render(f);
})
.unwrap();
let top_row: String = (0..80)
.map(|x| frame.buffer[(x, 0)].symbol().to_string())
.collect();
assert!(top_row.contains("branchdiff"), "title should show fallback, got: {}", top_row);
}
#[test]
fn test_apply_selection_with_active_selection() {
use crate::app::{Position, Selection};
use crate::ui::selection::SELECTION_BG_COLOR;
let spans = vec![Span::raw("hello world")];
let selection = Some(Selection {
start: Position { row: 0, col: 6 },
end: Position { row: 0, col: 11 },
active: false,
});
let result = apply_selection_to_content(spans, &selection, 0, 0);
assert!(result.len() > 1, "selection should split the span");
assert!(
result.iter().any(|s| s.style.bg == Some(SELECTION_BG_COLOR)),
"at least one span should have selection background"
);
}
#[test]
fn test_apply_selection_on_different_row() {
use crate::app::{Position, Selection};
let spans = vec![Span::raw("hello world")];
let selection = Some(Selection {
start: Position { row: 5, col: 0 },
end: Position { row: 5, col: 10 },
active: false,
});
let result = apply_selection_to_content(spans, &selection, 0, 0);
assert_eq!(result.len(), 1, "selection on different row should not split");
}
#[test]
fn test_line_num_width_scales_with_max_line_number() {
let mut lines = vec![DiffLine::file_header("test.rs")];
let mut line = base_line("content");
line.line_number = Some(999);
line.file_path = Some("test.rs".to_string());
lines.push(line);
let mut app = TestAppBuilder::new().with_lines(lines).build();
app.estimate_content_width(80);
let output = render_to_output(&app, 80, 24);
assert_eq!(output.line_num_width, 4, "999 = 3 digits + 1 space");
let mut lines = vec![DiffLine::file_header("test.rs")];
let mut line = base_line("content");
line.line_number = Some(9);
line.file_path = Some("test.rs".to_string());
lines.push(line);
let app = TestAppBuilder::new().with_lines(lines).build();
let output = render_to_output(&app, 80, 24);
assert_eq!(output.line_num_width, 2, "9 = 1 digit + 1 space");
let app = TestAppBuilder::new()
.with_lines(vec![DiffLine::file_header("test.rs")])
.build();
let output = render_to_output(&app, 80, 24);
assert_eq!(output.line_num_width, 0, "no line numbers = 0 width");
}
#[test]
fn search_highlight_no_search_returns_original() {
let spans = vec![Span::styled("hello world", Style::default())];
let result = apply_search_to_content(spans.clone(), &None, 0);
assert_eq!(result.len(), 1);
assert_eq!(result[0].content, "hello world");
}
#[test]
fn search_highlight_no_matches_on_line() {
let search = SearchState {
matches: vec![SearchMatch { line_idx: 5, char_start: 0, char_len: 3 }],
current: 0,
..Default::default()
};
let spans = vec![Span::styled("hello", Style::default())];
let result = apply_search_to_content(spans, &Some(search), 0);
assert_eq!(result.len(), 1);
assert_eq!(result[0].content, "hello");
}
#[test]
fn search_highlight_single_match() {
let search = SearchState {
matches: vec![SearchMatch { line_idx: 0, char_start: 6, char_len: 5 }],
current: 0,
..Default::default()
};
let spans = vec![Span::styled("hello world", Style::default())];
let result = apply_search_to_content(spans, &Some(search), 0);
assert_eq!(result.len(), 2);
assert_eq!(result[0].content, "hello ");
assert_eq!(result[1].content, "world");
assert_eq!(result[1].style.bg, Some(SEARCH_CURRENT_BG));
}
#[test]
fn search_highlight_non_current_match_uses_match_bg() {
let search = SearchState {
matches: vec![
SearchMatch { line_idx: 0, char_start: 0, char_len: 5 },
SearchMatch { line_idx: 1, char_start: 0, char_len: 5 },
],
current: 1, ..Default::default()
};
let spans = vec![Span::styled("hello world", Style::default())];
let result = apply_search_to_content(spans, &Some(search), 0);
assert_eq!(result[0].content, "hello");
assert_eq!(result[0].style.bg, Some(SEARCH_MATCH_BG));
}
#[test]
fn search_highlight_multiple_matches_same_line() {
let search = SearchState {
matches: vec![
SearchMatch { line_idx: 0, char_start: 0, char_len: 2 },
SearchMatch { line_idx: 0, char_start: 4, char_len: 2 },
],
current: 0,
..Default::default()
};
let spans = vec![Span::styled("ab cd ef", Style::default())];
let result = apply_search_to_content(spans, &Some(search), 0);
let highlighted: Vec<_> = result.iter().filter(|s| s.style.bg.is_some()).collect();
assert_eq!(highlighted.len(), 2);
}
#[test]
fn search_highlight_multibyte_unicode() {
let search = SearchState {
matches: vec![SearchMatch { line_idx: 0, char_start: 5, char_len: 6 }],
current: 0,
..Default::default()
};
let spans = vec![Span::styled("café résumé", Style::default())];
let result = apply_search_to_content(spans, &Some(search), 0);
assert_eq!(result.len(), 2);
assert_eq!(result[0].content, "café ");
assert_eq!(result[1].content, "résumé");
assert_eq!(result[1].style.bg, Some(SEARCH_CURRENT_BG));
}
#[test]
fn selection_on_wrapped_continuation_row_is_highlighted() {
use crate::app::{Position, Selection};
use crate::ui::selection::SELECTION_BG_COLOR;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let long_content = "x".repeat(162);
let mut lines = vec![DiffLine::file_header("test.rb")];
let mut line = base_line(&long_content);
line.line_number = Some(22);
line.file_path = Some("test.rb".to_string());
lines.push(line);
let width: u16 = 60;
let height: u16 = 10;
let mut app = TestAppBuilder::new().with_lines(lines).build();
app.estimate_content_width(width);
let output = render_to_output(&app, width, height);
let continuation_count = output.row_map.iter().filter(|r| r.is_continuation).count();
assert!(
continuation_count >= 2,
"expected at least 2 continuation rows, got {}. row_map len = {}, rows: {:?}",
continuation_count,
output.row_map.len(),
output.row_map.iter().map(|r| (r.is_continuation, r.content.len())).collect::<Vec<_>>()
);
let middle_row_idx = 2;
assert!(
output.row_map[middle_row_idx].is_continuation,
"row {} should be a continuation row",
middle_row_idx
);
app.view.selection = Some(Selection {
start: Position { row: middle_row_idx, col: 0 },
end: Position { row: middle_row_idx, col: width as usize },
active: false,
});
let ctx = FrameContext::new(&app);
let area = Rect::new(0, 0, width, height);
let view_model = DiffViewModel::from_app(&app, &ctx, area);
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
view_model.render(f);
})
.unwrap();
let screen_y = app.view.content_offset.1 as u16 + middle_row_idx as u16;
let buf = terminal.backend().buffer();
let has_selection_bg = (0..width).any(|x| buf[(x, screen_y)].bg == SELECTION_BG_COLOR);
assert!(
has_selection_bg,
"middle continuation row (screen_y={}) should have selection highlighting, \
but no cells had bg color {:?}. Cell bgs: {:?}",
screen_y,
SELECTION_BG_COLOR,
(0..width).map(|x| buf[(x, screen_y)].bg).collect::<Vec<_>>()
);
}
#[test]
fn selection_spanning_all_wrapped_rows_highlights_every_row() {
use crate::app::{Position, Selection};
use crate::ui::selection::SELECTION_BG_COLOR;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let long_content = "y".repeat(162);
let mut lines = vec![DiffLine::file_header("test.rb")];
let mut line = base_line(&long_content);
line.line_number = Some(10);
line.file_path = Some("test.rb".to_string());
lines.push(line);
let width: u16 = 60;
let height: u16 = 10;
let mut app = TestAppBuilder::new().with_lines(lines).build();
app.estimate_content_width(width);
let output = render_to_output(&app, width, height);
let first_content_row = 1;
let last_content_row = output.row_map.len() - 1;
assert!(last_content_row >= first_content_row + 2, "need at least 3 rows");
app.view.selection = Some(Selection {
start: Position { row: first_content_row, col: 0 },
end: Position { row: last_content_row, col: width as usize },
active: false,
});
let ctx = FrameContext::new(&app);
let area = Rect::new(0, 0, width, height);
let view_model = DiffViewModel::from_app(&app, &ctx, area);
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
view_model.render(f);
})
.unwrap();
let buf = terminal.backend().buffer();
let offset_y = app.view.content_offset.1 as u16;
for row_idx in first_content_row..=last_content_row {
let screen_y = offset_y + row_idx as u16;
let has_selection_bg = (0..width).any(|x| buf[(x, screen_y)].bg == SELECTION_BG_COLOR);
assert!(
has_selection_bg,
"row_map[{}] (screen_y={}, continuation={}) should have selection bg",
row_idx,
screen_y,
output.row_map[row_idx].is_continuation
);
}
}
#[test]
fn search_does_not_highlight_deletion_side_of_split_modification() {
use crate::app::search::{compute_matches, SearchState};
use crate::diff::InlineSpan;
use crate::ui::colors::{SEARCH_CURRENT_BG, SEARCH_MATCH_BG};
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut line = DiffLine::new(
LineSource::Committed,
"abc XX def".to_string(),
'+',
Some(1),
);
line.file_path = Some("test.rs".to_string());
line.old_content = Some("abc deleted XX def".to_string());
line.change_source = Some(LineSource::Committed);
line.inline_spans = vec![
InlineSpan { text: "abc ".to_string(), source: None, is_deletion: false },
InlineSpan { text: "deleted ".to_string(), source: Some(LineSource::Committed), is_deletion: true },
InlineSpan { text: "XX def".to_string(), source: None, is_deletion: false },
];
let mut app = TestAppBuilder::new()
.with_lines(vec![DiffLine::file_header("test.rs"), line])
.build();
app.estimate_content_width(80);
let mut s = SearchState::new();
s.query = "XX".to_string();
s.input_active = false;
s.matches = compute_matches(&app.lines, "XX");
s.current = 0;
app.search = Some(s);
let width: u16 = 80;
let height: u16 = 10;
let ctx = FrameContext::new(&app);
let area = Rect::new(0, 0, width, height);
let view_model = DiffViewModel::from_app(&app, &ctx, area);
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| { view_model.render(f); }).unwrap();
let buf = terminal.backend().buffer();
let offset_y = app.view.content_offset.1 as u16;
let del_y = offset_y + 1;
let ins_y = offset_y + 2;
let count_search_cells = |y: u16| -> usize {
(0..width)
.filter(|&x| {
let bg = buf[(x, y)].bg;
bg == SEARCH_CURRENT_BG || bg == SEARCH_MATCH_BG
})
.count()
};
assert!(
count_search_cells(ins_y) >= 2,
"insertion row should have ≥2 search-bg cells (for 'XX'), got {}",
count_search_cells(ins_y)
);
let del_hits: Vec<u16> = (0..width)
.filter(|&x| {
let bg = buf[(x, del_y)].bg;
bg == SEARCH_CURRENT_BG || bg == SEARCH_MATCH_BG
})
.collect();
assert!(
del_hits.is_empty(),
"deletion row must not show search highlight (search matches refer \
to new content), but cells {:?} have search bg",
del_hits
);
}
mod content_to_render {
use super::*;
use crate::diff::InlineSpan;
fn unchanged(text: &str) -> InlineSpan {
InlineSpan { text: text.to_string(), source: None, is_deletion: false }
}
fn insertion(text: &str) -> InlineSpan {
InlineSpan { text: text.to_string(), source: Some(LineSource::Committed), is_deletion: false }
}
fn deletion(text: &str) -> InlineSpan {
InlineSpan { text: text.to_string(), source: Some(LineSource::Committed), is_deletion: true }
}
#[test]
fn empty_range_yields_nothing() {
let spans = vec![unchanged("hello")];
assert!(content_match_to_render_ranges(&spans, 3, 3).is_empty());
assert!(content_match_to_render_ranges(&spans, 5, 2).is_empty());
}
#[test]
fn no_deletions_is_identity() {
let spans = vec![unchanged("hello "), insertion("world")];
assert_eq!(
content_match_to_render_ranges(&spans, 6, 11),
vec![(6, 11)]
);
assert_eq!(
content_match_to_render_ranges(&spans, 0, 5),
vec![(0, 5)]
);
}
#[test]
fn deletion_before_match_shifts_render_position() {
let spans = vec![deletion("DEL"), insertion("abc")];
assert_eq!(
content_match_to_render_ranges(&spans, 0, 3),
vec![(3, 6)]
);
}
#[test]
fn deletion_after_match_does_not_shift() {
let spans = vec![insertion("abc"), deletion("DEL")];
assert_eq!(
content_match_to_render_ranges(&spans, 0, 3),
vec![(0, 3)]
);
}
#[test]
fn match_split_across_interior_deletion_yields_two_ranges() {
let spans = vec![unchanged("ab"), deletion("DEL"), insertion("cd")];
assert_eq!(
content_match_to_render_ranges(&spans, 0, 4),
vec![(0, 2), (5, 7)]
);
}
#[test]
fn match_inside_a_single_unchanged_span_is_translated() {
let spans = vec![deletion("DEL"), unchanged("hello")];
assert_eq!(
content_match_to_render_ranges(&spans, 1, 4),
vec![(4, 7)]
);
}
#[test]
fn adjacent_non_deletion_spans_coalesce_into_single_range() {
let spans = vec![unchanged("ab"), insertion("cd")];
assert_eq!(
content_match_to_render_ranges(&spans, 1, 3),
vec![(1, 3)]
);
}
#[test]
fn multiple_deletions_accumulate_render_shift() {
let spans = vec![deletion("D1"), unchanged("ab"), deletion("D2"), insertion("cd")];
assert_eq!(
content_match_to_render_ranges(&spans, 2, 4),
vec![(6, 8)]
);
}
#[test]
fn match_outside_content_yields_nothing() {
let spans = vec![unchanged("ab")];
assert!(content_match_to_render_ranges(&spans, 5, 7).is_empty());
}
#[test]
fn multibyte_chars_use_char_count_not_byte_count() {
let spans = vec![deletion("café"), insertion("ok")];
assert_eq!(
content_match_to_render_ranges(&spans, 0, 2),
vec![(4, 6)]
);
}
}
#[test]
fn search_highlight_aligned_on_inline_mixed_render() {
use crate::app::search::{compute_matches, SearchState};
use crate::diff::InlineSpan;
use crate::ui::colors::{SEARCH_CURRENT_BG, SEARCH_MATCH_BG};
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut line = DiffLine::new(
LineSource::Committed,
"abcd ZZ ef".to_string(),
' ',
Some(1),
);
line.file_path = Some("test.rs".to_string());
line.old_content = Some("abDEL ef".to_string());
line.change_source = Some(LineSource::Committed);
line.inline_spans = vec![
InlineSpan { text: "ab".to_string(), source: None, is_deletion: false },
InlineSpan { text: "DEL".to_string(), source: Some(LineSource::Committed), is_deletion: true },
InlineSpan { text: "cd ZZ ef".to_string(), source: Some(LineSource::Committed), is_deletion: false },
];
let mut app = TestAppBuilder::new()
.with_lines(vec![DiffLine::file_header("test.rs"), line])
.build();
app.estimate_content_width(80);
let mut s = SearchState::new();
s.query = "ZZ".to_string();
s.input_active = false;
s.matches = compute_matches(&app.lines, "ZZ");
s.current = 0;
app.search = Some(s);
let width: u16 = 80;
let height: u16 = 10;
let ctx = FrameContext::new(&app);
let area = Rect::new(0, 0, width, height);
let view_model = DiffViewModel::from_app(&app, &ctx, area);
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| { view_model.render(f); }).unwrap();
let buf = terminal.backend().buffer();
let offset_y = app.view.content_offset.1 as u16;
let row_y = offset_y + 1;
let highlighted: String = (0..width)
.filter_map(|x| {
let cell = &buf[(x, row_y)];
if cell.bg == SEARCH_CURRENT_BG || cell.bg == SEARCH_MATCH_BG {
Some(cell.symbol().to_string())
} else {
None
}
})
.collect();
assert_eq!(
highlighted, "ZZ",
"search highlight on inline-mixed line should cover exactly 'ZZ'; \
got {:?}",
highlighted
);
}
#[test]
fn search_highlight_split_by_interior_deletion_yields_two_regions() {
use crate::app::search::{compute_matches, SearchState};
use crate::diff::InlineSpan;
use crate::ui::colors::{SEARCH_CURRENT_BG, SEARCH_MATCH_BG};
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut line = DiffLine::new(
LineSource::Committed,
"abcd".to_string(),
' ',
Some(1),
);
line.file_path = Some("test.rs".to_string());
line.old_content = Some("abDEL".to_string());
line.change_source = Some(LineSource::Committed);
line.inline_spans = vec![
InlineSpan { text: "ab".to_string(), source: None, is_deletion: false },
InlineSpan { text: "DEL".to_string(), source: Some(LineSource::Committed), is_deletion: true },
InlineSpan { text: "cd".to_string(), source: Some(LineSource::Committed), is_deletion: false },
];
let mut app = TestAppBuilder::new()
.with_lines(vec![DiffLine::file_header("test.rs"), line])
.build();
app.estimate_content_width(80);
let mut s = SearchState::new();
s.query = "abcd".to_string();
s.input_active = false;
s.matches = compute_matches(&app.lines, "abcd");
s.current = 0;
app.search = Some(s);
let width: u16 = 80;
let height: u16 = 10;
let ctx = FrameContext::new(&app);
let area = Rect::new(0, 0, width, height);
let view_model = DiffViewModel::from_app(&app, &ctx, area);
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| { view_model.render(f); }).unwrap();
let buf = terminal.backend().buffer();
let offset_y = app.view.content_offset.1 as u16;
let row_y = offset_y + 1;
let highlighted: String = (0..width)
.filter_map(|x| {
let cell = &buf[(x, row_y)];
if cell.bg == SEARCH_CURRENT_BG || cell.bg == SEARCH_MATCH_BG {
Some(cell.symbol().to_string())
} else {
None
}
})
.collect();
assert_eq!(
highlighted, "abcd",
"match straddling deletion should highlight only the matched \
characters, not the interleaved deletion text; got {:?}",
highlighted
);
}
}