use std::path::Path;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::git::{DiffLine, DiffLineKind};
const HEADER_FG: Color = Color::Rgb(122, 162, 247);
fn is_file_header(dl: &DiffLine) -> bool {
matches!(dl.kind, DiffLineKind::Context) && dl.old_no.is_none() && dl.new_no.is_none()
}
fn ext_from_path(path: &str) -> String {
Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_string()
}
fn file_header_line(path: &str, width: usize) -> Line<'static> {
let line_style = Style::new().fg(HEADER_FG);
let name_style = line_style.add_modifier(Modifier::BOLD);
let label = format!(" {path} ");
let label_w = UnicodeWidthStr::width(label.as_str());
let used = 2 + label_w + 1; let fill = width.saturating_sub(used);
Line::from(vec![
Span::styled("┌─".to_string(), line_style),
Span::styled(label, name_style),
Span::styled("─".repeat(fill), line_style),
Span::styled("┐".to_string(), line_style),
])
}
const BG_ADDED: Color = Color::Rgb(20, 48, 28);
const BG_REMOVED: Color = Color::Rgb(58, 24, 26);
const BG_ADDED_STRONG: Color = Color::Rgb(40, 92, 54);
const BG_REMOVED_STRONG: Color = Color::Rgb(104, 40, 46);
const BAR_ADDED: Color = Color::Rgb(87, 171, 90);
const BAR_REMOVED: Color = Color::Rgb(199, 84, 80);
const GUTTER_W: usize = 4;
fn word_change_range(old: &str, new: &str) -> ((usize, usize), (usize, usize)) {
let o: Vec<char> = old.chars().collect();
let n: Vec<char> = new.chars().collect();
let cap = o.len().min(n.len());
let mut p = 0;
while p < cap && o[p] == n[p] {
p += 1;
}
let mut s = 0;
while s < cap - p && o[o.len() - 1 - s] == n[n.len() - 1 - s] {
s += 1;
}
((p, o.len() - s), (p, n.len() - s))
}
fn intra_ranges(diff: &[DiffLine]) -> Vec<Option<(usize, usize)>> {
let mut ranges: Vec<Option<(usize, usize)>> = vec![None; diff.len()];
let mut rem: Vec<usize> = Vec::new();
let mut add: Vec<usize> = Vec::new();
for (i, dl) in diff.iter().enumerate() {
match dl.kind {
DiffLineKind::Removed => {
if !add.is_empty() {
pair_ranges(diff, &mut rem, &mut add, &mut ranges);
}
rem.push(i);
}
DiffLineKind::Added => add.push(i),
DiffLineKind::Context => pair_ranges(diff, &mut rem, &mut add, &mut ranges),
}
}
pair_ranges(diff, &mut rem, &mut add, &mut ranges);
ranges
}
fn pair_ranges(
diff: &[DiffLine],
rem: &mut Vec<usize>,
add: &mut Vec<usize>,
ranges: &mut [Option<(usize, usize)>],
) {
let n = rem.len().min(add.len());
for i in 0..n {
let (oi, ni) = (rem[i], add[i]);
let (ro, rn) = word_change_range(&diff[oi].text, &diff[ni].text);
let o_len = diff[oi].text.chars().count();
let n_len = diff[ni].text.chars().count();
let has_common = ro.0 > 0 || ro.1 < o_len || rn.0 > 0 || rn.1 < n_len;
if has_common {
ranges[oi] = Some(ro);
ranges[ni] = Some(rn);
}
}
rem.clear();
add.clear();
}
fn overlay_intra_bg(
spans: Vec<Span<'static>>,
base: Color,
strong: Color,
range: Option<(usize, usize)>,
) -> Vec<Span<'static>> {
let (start, end) = match range {
Some(r) => r,
None => {
return spans
.into_iter()
.map(|mut sp| {
sp.style = sp.style.bg(base);
sp
})
.collect();
}
};
let mut out: Vec<Span<'static>> = Vec::new();
let mut idx = 0usize; for sp in spans {
let mut cur = String::new();
let mut cur_strong = false;
let mut started = false;
for ch in sp.content.chars() {
let is_strong = idx >= start && idx < end;
if !started {
cur_strong = is_strong;
started = true;
} else if is_strong != cur_strong {
let bg = if cur_strong { strong } else { base };
out.push(Span::styled(std::mem::take(&mut cur), sp.style.bg(bg)));
cur_strong = is_strong;
}
cur.push(ch);
idx += 1;
}
if !cur.is_empty() {
let bg = if cur_strong { strong } else { base };
out.push(Span::styled(cur, sp.style.bg(bg)));
}
}
out
}
#[cfg(test)]
thread_local! {
static HIGHLIGHT_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
fn reset_highlight_calls() {
HIGHLIGHT_CALLS.with(|c| c.set(0));
}
#[cfg(test)]
fn highlight_calls() -> usize {
HIGHLIGHT_CALLS.with(|c| c.get())
}
#[cfg(test)]
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub(crate) fn reset_highlight_calls_for_test() {
reset_highlight_calls();
}
#[cfg(test)]
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub(crate) fn highlight_calls_for_test() -> usize {
highlight_calls()
}
#[cfg(test)]
fn record_highlight_call() {
HIGHLIGHT_CALLS.with(|c| c.set(c.get() + 1));
}
#[cfg(not(test))]
fn record_highlight_call() {}
pub fn diff_line_to_line(
dl: &DiffLine,
intra: Option<(usize, usize)>,
ext: &str,
theme: &str,
) -> Line<'static> {
let (row_bg, strong_bg, bar_color, bar) = match dl.kind {
DiffLineKind::Added => (Some(BG_ADDED), Some(BG_ADDED_STRONG), Some(BAR_ADDED), "▌"),
DiffLineKind::Removed => (
Some(BG_REMOVED),
Some(BG_REMOVED_STRONG),
Some(BAR_REMOVED),
"▌",
),
DiffLineKind::Context => (None, None, None, " "),
};
let old = dl
.old_no
.map(|n| format!("{n:>GUTTER_W$}"))
.unwrap_or_else(|| " ".repeat(GUTTER_W));
let new = dl
.new_no
.map(|n| format!("{n:>GUTTER_W$}"))
.unwrap_or_else(|| " ".repeat(GUTTER_W));
let mut dim = Style::new().fg(Color::DarkGray);
if let Some(bg) = row_bg {
dim = dim.bg(bg);
}
let mut bar_style = Style::new();
if let Some(c) = bar_color {
bar_style = bar_style.fg(c);
}
if let Some(bg) = row_bg {
bar_style = bar_style.bg(bg);
}
let mut spans: Vec<Span<'static>> = vec![
Span::styled(bar.to_string(), bar_style),
Span::styled(old, dim),
Span::styled(" ", dim),
Span::styled(new, dim),
Span::styled(" ", dim),
];
let content = if dl.text.is_empty() {
vec![Span::raw(String::new())]
} else {
record_highlight_call();
crate::preview::code::highlight_line_by_ext(&dl.text, ext, theme)
};
match (row_bg, strong_bg) {
(Some(base), Some(strong)) => spans.extend(overlay_intra_bg(content, base, strong, intra)),
_ => spans.extend(content), }
Line::from(spans)
}
fn ext_at(diff: &[DiffLine], default_ext: &str, at: usize) -> String {
diff[..at.min(diff.len())]
.iter()
.rev()
.find(|dl| is_file_header(dl))
.map(|dl| ext_from_path(&dl.text))
.unwrap_or_else(|| default_ext.to_string())
}
pub fn diff_lines_range(
diff: &[DiffLine],
default_ext: &str,
theme: &str,
width: usize,
start: usize,
count: usize,
) -> Vec<Line<'static>> {
let start = start.min(diff.len());
let end = start.saturating_add(count).min(diff.len());
if start >= end {
return Vec::new();
}
let ranges = intra_ranges(diff);
let mut cur_ext = ext_at(diff, default_ext, start);
diff[start..end]
.iter()
.enumerate()
.map(|(off, dl)| {
let i = start + off;
if is_file_header(dl) {
cur_ext = ext_from_path(&dl.text); file_header_line(&dl.text, width)
} else {
diff_line_to_line(dl, ranges[i], &cur_ext, theme)
}
})
.collect()
}
pub fn diff_lines(
diff: &[DiffLine],
default_ext: &str,
theme: &str,
width: usize,
) -> Vec<Line<'static>> {
diff_lines_range(diff, default_ext, theme, width, 0, diff.len())
}
pub fn unified_max_hscroll(diff: &[DiffLine], width: usize) -> usize {
const PREFIX_W: usize = 1 + GUTTER_W + 1 + GUTTER_W + 1; let max_content = diff
.iter()
.map(|dl| {
if is_file_header(dl) {
let label_w = UnicodeWidthStr::width(format!(" {} ", dl.text).as_str());
(2 + label_w + 1).max(width)
} else {
PREFIX_W + UnicodeWidthStr::width(dl.text.as_str())
}
})
.max()
.unwrap_or(0);
max_content.saturating_sub(width)
}
#[derive(Clone)]
struct Half {
no: Option<u32>,
text: String,
bg: Option<Color>,
bar: Option<Color>,
hl: Option<(usize, usize)>,
ext: String,
}
enum SideRow {
Header(String),
Pair(Option<Half>, Option<Half>),
}
pub fn side_by_side_row_count(diff: &[DiffLine], default_ext: &str) -> usize {
build_side_rows(diff, default_ext).len()
}
pub fn diff_lines_side_by_side_range(
diff: &[DiffLine],
default_ext: &str,
theme: &str,
width: usize,
hscroll: usize,
start: usize,
count: usize,
) -> Vec<Line<'static>> {
let sep_w = 1usize;
let left_w = width.saturating_sub(sep_w) / 2;
let right_w = width.saturating_sub(left_w + sep_w);
build_side_rows(diff, default_ext)
.into_iter()
.skip(start)
.take(count)
.map(|row| render_side_row(row, theme, left_w, right_w, hscroll))
.collect()
}
pub fn diff_lines_side_by_side(
diff: &[DiffLine],
default_ext: &str,
theme: &str,
width: usize,
hscroll: usize,
) -> Vec<Line<'static>> {
diff_lines_side_by_side_range(diff, default_ext, theme, width, hscroll, 0, diff.len())
}
pub fn side_by_side_max_hscroll(diff: &[DiffLine], width: usize) -> usize {
let left_w = width.saturating_sub(1) / 2;
let budget = left_w.saturating_sub(GUTTER_W + 2); let max_content = diff
.iter()
.filter(|dl| !is_file_header(dl))
.map(|dl| UnicodeWidthStr::width(dl.text.as_str()))
.max()
.unwrap_or(0);
max_content.saturating_sub(budget)
}
fn build_side_rows(diff: &[DiffLine], default_ext: &str) -> Vec<SideRow> {
let ranges = intra_ranges(diff);
let mut cur_ext = default_ext.to_string();
let mut rows = Vec::new();
let mut rem: Vec<Half> = Vec::new();
let mut add: Vec<Half> = Vec::new();
for (i, dl) in diff.iter().enumerate() {
match dl.kind {
DiffLineKind::Removed => {
if !add.is_empty() {
flush_block(&mut rows, &mut rem, &mut add);
}
rem.push(Half {
no: dl.old_no,
text: dl.text.clone(),
bg: Some(BG_REMOVED),
bar: Some(BAR_REMOVED),
hl: ranges[i],
ext: cur_ext.clone(),
});
}
DiffLineKind::Added => add.push(Half {
no: dl.new_no,
text: dl.text.clone(),
bg: Some(BG_ADDED),
bar: Some(BAR_ADDED),
hl: ranges[i],
ext: cur_ext.clone(),
}),
DiffLineKind::Context => {
flush_block(&mut rows, &mut rem, &mut add);
if is_file_header(dl) {
cur_ext = ext_from_path(&dl.text); rows.push(SideRow::Header(dl.text.clone()));
} else {
let mk = |no| Half {
no,
text: dl.text.clone(),
bg: None,
bar: None,
hl: None,
ext: cur_ext.clone(),
};
rows.push(SideRow::Pair(Some(mk(dl.old_no)), Some(mk(dl.new_no))));
}
}
}
}
flush_block(&mut rows, &mut rem, &mut add);
rows
}
fn flush_block(rows: &mut Vec<SideRow>, rem: &mut Vec<Half>, add: &mut Vec<Half>) {
let n = rem.len().max(add.len());
for i in 0..n {
rows.push(SideRow::Pair(rem.get(i).cloned(), add.get(i).cloned()));
}
rem.clear();
add.clear();
}
fn render_side_row(
row: SideRow,
theme: &str,
left_w: usize,
right_w: usize,
hscroll: usize,
) -> Line<'static> {
match row {
SideRow::Header(text) => file_header_line(&text, left_w + 1 + right_w),
SideRow::Pair(left, right) => {
let mut spans = render_half(left, theme, left_w, hscroll);
spans.push(Span::styled("│", Style::new().fg(Color::DarkGray)));
spans.extend(render_half(right, theme, right_w, hscroll));
Line::from(spans)
}
}
}
fn render_half(
half: Option<Half>,
theme: &str,
col_w: usize,
hscroll: usize,
) -> Vec<Span<'static>> {
let Some(h) = half else {
return vec![Span::raw(" ".repeat(col_w))];
};
let bg = h.bg;
let no =
h.no.map(|n| format!("{n:>GUTTER_W$}"))
.unwrap_or_else(|| " ".repeat(GUTTER_W));
let dim = {
let s = Style::new().fg(Color::DarkGray);
if let Some(b) = bg {
s.bg(b)
} else {
s
}
};
let bar_style = {
let mut s = Style::new();
if let Some(c) = h.bar {
s = s.fg(c);
}
if let Some(b) = bg {
s = s.bg(b);
}
s
};
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::styled(
if h.bar.is_some() { "▌" } else { " " }.to_string(),
bar_style,
));
spans.push(Span::styled(no, dim));
spans.push(Span::styled(" ", dim));
let budget = col_w.saturating_sub(GUTTER_W + 2); let content = if h.text.is_empty() {
Vec::new()
} else {
record_highlight_call();
crate::preview::code::highlight_line_by_ext(&h.text, &h.ext, theme)
};
let content = if let Some(base) = bg {
let strong = if base == BG_ADDED {
BG_ADDED_STRONG
} else {
BG_REMOVED_STRONG
};
overlay_intra_bg(content, base, strong, h.hl)
} else {
content };
let (clipped, used) = clip_spans_window(content, hscroll, budget);
spans.extend(clipped);
let pad = budget.saturating_sub(used);
if pad > 0 {
let pad_style = bg.map(|b| Style::new().bg(b)).unwrap_or_default();
spans.push(Span::styled(" ".repeat(pad), pad_style));
}
spans
}
fn clip_spans_window(
spans: Vec<Span<'static>>,
skip: usize,
take: usize,
) -> (Vec<Span<'static>>, usize) {
let end = skip.saturating_add(take);
let mut out = Vec::new();
let mut col = 0usize; let mut used = 0usize; for sp in spans {
if col >= end {
break;
}
let mut buf = String::new();
for ch in sp.content.chars() {
if col >= end {
break;
}
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if col >= skip && col + cw <= end {
buf.push(ch);
used += cw;
}
col += cw;
}
if !buf.is_empty() {
out.push(Span::styled(buf, sp.style));
}
}
(out, used)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::{DiffLine, DiffLineKind};
fn bg_colors(line: &Line<'static>) -> Vec<Option<Color>> {
line.spans.iter().map(|s| s.style.bg).collect()
}
#[test]
fn added_line_has_green_bg_and_bar() {
let dl = DiffLine {
kind: DiffLineKind::Added,
old_no: None,
new_no: Some(3),
text: "let x = 1;".into(),
};
let line = diff_line_to_line(&dl, None, "rs", "TwoDark");
assert!(
bg_colors(&line).contains(&Some(BG_ADDED)),
"Added 背景が無い"
);
assert!(
line.spans
.iter()
.any(|s| s.content.as_ref() == "▌" && s.style.fg == Some(BAR_ADDED)),
"緑の変更バーが無い"
);
}
#[test]
fn removed_line_has_red_bg() {
let dl = DiffLine {
kind: DiffLineKind::Removed,
old_no: Some(2),
new_no: None,
text: "let y = 2;".into(),
};
let line = diff_line_to_line(&dl, None, "rs", "TwoDark");
assert!(
bg_colors(&line).contains(&Some(BG_REMOVED)),
"Removed 背景が無い"
);
}
#[test]
fn side_by_side_splits_old_left_new_right() {
use DiffLineKind::*;
let diff = vec![
DiffLine {
kind: Context,
old_no: Some(1),
new_no: Some(1),
text: "ctx".into(),
},
DiffLine {
kind: Removed,
old_no: Some(2),
new_no: None,
text: "old line".into(),
},
DiffLine {
kind: Added,
old_no: None,
new_no: Some(2),
text: "new line".into(),
},
];
let lines = diff_lines_side_by_side(&diff, "rs", "TwoDark", 60, 0);
for l in &lines {
let s: String = l.spans.iter().map(|sp| sp.content.as_ref()).collect();
assert!(s.contains('│'), "区切り │ が無い: {s}");
assert!(UnicodeWidthStr::width(s.as_str()) <= 60, "幅超過: {s}");
}
let change: String = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|sp| sp.content.as_ref())
.collect::<String>()
})
.find(|s| s.contains("old line"))
.expect("変更行が無い");
let bar = change.find('│').unwrap();
assert!(change.find("old line").unwrap() < bar, "old は左: {change}");
assert!(change.find("new line").unwrap() > bar, "new は右: {change}");
let row = lines
.iter()
.find(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
.contains("old line")
})
.unwrap();
assert!(
row.spans
.iter()
.any(|s| s.style.bg == Some(BG_REMOVED) || s.style.bg == Some(BG_REMOVED_STRONG)),
"左に赤背景が無い"
);
assert!(
row.spans
.iter()
.any(|s| s.style.bg == Some(BG_ADDED) || s.style.bg == Some(BG_ADDED_STRONG)),
"右に緑背景が無い"
);
}
#[test]
fn side_by_side_hscroll_moves_content_not_gutter() {
use DiffLineKind::*;
let long = format!("START{}END", "x".repeat(40)); let diff = vec![DiffLine {
kind: Context,
old_no: Some(7),
new_no: Some(7),
text: long,
}];
let row = |hscroll: usize| -> String {
diff_lines_side_by_side(&diff, "txt", "TwoDark", 40, hscroll)[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
};
let s0 = row(0);
assert!(s0.contains("START") && !s0.contains("END"), "0: {s0}");
assert!(
s0.contains('7') && s0.contains('│'),
"行番号/区切りが出る: {s0}"
);
let max = side_by_side_max_hscroll(&diff, 40);
assert!(max > 0, "横スクロール可能幅がある");
let se = row(max);
assert!(se.contains("END") && !se.contains("START"), "max: {se}");
assert!(
se.contains('7') && se.contains('│'),
"横移動してもガター/区切りは固定: {se}"
);
}
#[test]
fn intra_line_highlights_only_changed_chars() {
use DiffLineKind::*;
let diff = vec![
DiffLine {
kind: Removed,
old_no: Some(2),
new_no: None,
text: " let x = 1;".into(),
},
DiffLine {
kind: Added,
old_no: None,
new_no: Some(2),
text: " let x = 2;".into(),
},
];
let lines = diff_lines(&diff, "rs", "TwoDark", 80);
assert!(
lines[0]
.spans
.iter()
.any(|s| s.content.as_ref() == "1" && s.style.bg == Some(BG_REMOVED_STRONG)),
"削除行: 変更文字 '1' が明るい背景でない"
);
assert!(
lines[0]
.spans
.iter()
.any(|s| s.style.bg == Some(BG_REMOVED)),
"削除行: 通常の赤背景が無い(全部 strong になっている)"
);
assert!(
lines[1]
.spans
.iter()
.any(|s| s.content.as_ref() == "2" && s.style.bg == Some(BG_ADDED_STRONG)),
"追加行: 変更文字 '2' が明るい背景でない"
);
let sbs = diff_lines_side_by_side(&diff, "rs", "TwoDark", 60, 0);
let has_red_strong = sbs.iter().any(|l| {
l.spans
.iter()
.any(|s| s.content.as_ref() == "1" && s.style.bg == Some(BG_REMOVED_STRONG))
});
let has_green_strong = sbs.iter().any(|l| {
l.spans
.iter()
.any(|s| s.content.as_ref() == "2" && s.style.bg == Some(BG_ADDED_STRONG))
});
assert!(
has_red_strong && has_green_strong,
"横並びでも変更文字が明るい背景"
);
}
#[test]
fn file_header_is_framed_and_per_file_syntax_applies() {
use DiffLineKind::*;
let diff = vec![
DiffLine {
kind: Context,
old_no: None,
new_no: None,
text: "src/main.rs".into(),
},
DiffLine {
kind: Context,
old_no: Some(1),
new_no: Some(1),
text: "fn main() {}".into(),
},
];
let lines = diff_lines(&diff, "", "TwoDark", 60);
let hdr: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
hdr.starts_with('┌') && hdr.ends_with('┐'),
"枠ヘッダでない: {hdr}"
);
assert!(hdr.contains("src/main.rs"), "パスが無い: {hdr}");
assert!(
lines[0].spans.iter().any(|s| s.style.fg == Some(HEADER_FG)),
"枠が青(HEADER_FG)でない"
);
assert!(!hdr.contains("── "), "旧 `── ` 装飾が残っている");
let colors: std::collections::HashSet<(u8, u8, u8)> = lines[1]
.spans
.iter()
.filter_map(|s| match s.style.fg {
Some(Color::Rgb(r, g, b)) => Some((r, g, b)),
_ => None,
})
.collect();
assert!(
colors.len() >= 2,
"rs として構文着色されていない (色数 {})",
colors.len()
);
}
#[test]
fn context_line_has_no_row_bg() {
let dl = DiffLine {
kind: DiffLineKind::Context,
old_no: Some(1),
new_no: Some(1),
text: "fn main() {".into(),
};
let line = diff_line_to_line(&dl, None, "rs", "TwoDark");
assert!(
bg_colors(&line).iter().all(|b| b.is_none()),
"Context に背景が乗っている"
);
let joined: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(!joined.trim_start().starts_with('+'));
assert!(!joined.contains("─")); }
#[test]
fn side_by_side_empty_cell_and_hscroll_paths() {
use DiffLineKind::*;
let diff = vec![
DiffLine {
kind: Removed,
old_no: Some(2),
new_no: None,
text: "removed first line".into(),
},
DiffLine {
kind: Removed,
old_no: Some(3),
new_no: None,
text: "removed second line".into(),
},
DiffLine {
kind: Added,
old_no: None,
new_no: Some(2),
text: "added only line".into(),
},
];
let lines = diff_lines_side_by_side(&diff, "rs", "TwoDark", 60, 0);
assert!(!lines.is_empty());
for l in &lines {
let s: String = l.spans.iter().map(|sp| sp.content.as_ref()).collect();
assert!(s.contains('│'), "区切りが無い: {s}");
assert!(UnicodeWidthStr::width(s.as_str()) <= 60, "幅超過: {s}");
}
let row: String = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|sp| sp.content.as_ref())
.collect::<String>()
})
.find(|s| s.contains("removed second"))
.expect("削除2行目が無い");
let bar = row.find('│').unwrap();
let right = &row[bar + '│'.len_utf8()..];
assert!(right.trim().is_empty(), "右側は空セル(空白): {right:?}");
let shifted = diff_lines_side_by_side(&diff, "rs", "TwoDark", 60, 6);
assert!(!shifted.is_empty());
for l in &shifted {
let s: String = l.spans.iter().map(|sp| sp.content.as_ref()).collect();
assert!(
UnicodeWidthStr::width(s.as_str()) <= 60,
"横スクロール後も幅 60 以内: {s}"
);
}
}
fn synthetic_diff(files: usize, lines_per_file: usize) -> Vec<DiffLine> {
use DiffLineKind::*;
let exts = ["rs", "py"];
let mut diff = Vec::new();
for f in 0..files {
let ext = exts[f % exts.len()];
diff.push(DiffLine {
kind: Context,
old_no: None,
new_no: None,
text: format!("src/file{f}.{ext}"),
});
let mut old_no = 1u32;
let mut new_no = 1u32;
for i in 0..lines_per_file {
match i % 5 {
0 | 1 => {
diff.push(DiffLine {
kind: Context,
old_no: Some(old_no),
new_no: Some(new_no),
text: format!(" ctx line {i} in file {f}"),
});
old_no += 1;
new_no += 1;
}
2 | 3 => {
diff.push(DiffLine {
kind: Removed,
old_no: Some(old_no),
new_no: None,
text: format!(" let value = {i}; // old file {f}"),
});
old_no += 1;
diff.push(DiffLine {
kind: Added,
old_no: None,
new_no: Some(new_no),
text: format!(" let value = {}; // new file {f}", i * 2),
});
new_no += 1;
}
_ => {
diff.push(DiffLine {
kind: Added,
old_no: None,
new_no: Some(new_no),
text: format!(" // appended note {i} in file {f}"),
});
new_no += 1;
}
}
}
}
diff
}
#[test]
fn windowed_range_only_highlights_visible_rows_unified() {
let diff = synthetic_diff(2, 400); reset_highlight_calls();
let window = diff_lines_range(&diff, "txt", "TwoDark", 100, 500, 40);
let calls = highlight_calls();
assert_eq!(window.len(), 40, "要求した高さぶんだけ返る");
assert!(
calls > 0 && calls <= 80,
"可視範囲(高さ40)を大きく超えてハイライトしている(全 {} 行中 {calls} 回): O(diff全体)に戻っていないか",
diff.len()
);
}
#[test]
fn windowed_range_only_highlights_visible_rows_side_by_side() {
let diff = synthetic_diff(2, 400);
reset_highlight_calls();
let window = diff_lines_side_by_side_range(&diff, "txt", "TwoDark", 100, 0, 300, 40);
let calls = highlight_calls();
assert_eq!(window.len(), 40, "要求した高さぶんだけ返る");
assert!(
calls > 0 && calls <= 160,
"可視範囲(高さ40×2列)を大きく超えてハイライトしている({calls} 回): O(diff全体)に戻っていないか"
);
}
fn render_to_buffer(
lines: Vec<Line<'static>>,
width: u16,
height: u16,
hscroll: u16,
) -> ratatui::buffer::Buffer {
use ratatui::layout::Rect;
use ratatui::text::Text;
use ratatui::widgets::Paragraph;
use ratatui::Terminal;
let area = Rect {
x: 0,
y: 0,
width,
height,
};
let mut term = Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
term.draw(|f| {
let para = Paragraph::new(Text::from(lines)).scroll((0, hscroll));
f.render_widget(para, area);
})
.unwrap();
term.backend().buffer().clone()
}
fn render_full_scrolled(
lines: Vec<Line<'static>>,
width: u16,
height: u16,
scroll: u16,
hscroll: u16,
) -> ratatui::buffer::Buffer {
use ratatui::layout::Rect;
use ratatui::text::Text;
use ratatui::widgets::Paragraph;
use ratatui::Terminal;
let area = Rect {
x: 0,
y: 0,
width,
height,
};
let mut term = Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
term.draw(|f| {
let para = Paragraph::new(Text::from(lines)).scroll((scroll, hscroll));
f.render_widget(para, area);
})
.unwrap();
term.backend().buffer().clone()
}
fn assert_buffers_match(
actual: &ratatui::buffer::Buffer,
reference: &ratatui::buffer::Buffer,
width: u16,
height: u16,
ctx: &str,
) {
for y in 0..height {
for x in 0..width {
let a = &actual[(x, y)];
let r = &reference[(x, y)];
assert_eq!(
(a.symbol(), a.fg, a.bg, a.modifier),
(r.symbol(), r.fg, r.bg, r.modifier),
"{ctx}: cell({x},{y}) がフル描画と不一致"
);
}
}
}
#[test]
fn diff_lines_range_matches_full_render_across_scroll_positions_and_file_boundary() {
let diff = synthetic_diff(2, 400);
let total = diff.len();
let (iw, ih) = (72u16, 18u16);
let full = diff_lines(&diff, "txt", "TwoDark", iw as usize);
assert_eq!(full.len(), total, "diff_lines は1行=1DiffLineのまま");
let file2_header = diff
.iter()
.position(|dl| is_file_header(dl) && dl.text.ends_with(".py"))
.expect("2つ目のファイルヘッダがある");
let max_v = total.saturating_sub(ih as usize);
let scrolls = [
0usize,
37,
file2_header.saturating_sub(2),
file2_header,
file2_header + 3,
max_v,
];
for scroll in scrolls {
let window =
diff_lines_range(&diff, "txt", "TwoDark", iw as usize, scroll, ih as usize);
let actual = render_to_buffer(window, iw, ih, 0);
let reference = render_full_scrolled(full.clone(), iw, ih, scroll as u16, 0);
assert_buffers_match(&actual, &reference, iw, ih, &format!("scroll={scroll}"));
}
}
#[test]
fn diff_lines_range_preserves_intra_pairing_when_window_splits_a_change_block() {
use DiffLineKind::*;
let mut diff = Vec::new();
for k in 0..30u32 {
diff.push(DiffLine {
kind: Removed,
old_no: Some(k + 1),
new_no: None,
text: format!("value_{k} = OLD_{k};"),
});
}
for k in 0..30u32 {
diff.push(DiffLine {
kind: Added,
old_no: None,
new_no: Some(k + 1),
text: format!("value_{k} = NEW_{k}_CHANGED;"),
});
}
let (iw, ih) = (60u16, 20u16);
let full = diff_lines(&diff, "txt", "TwoDark", iw as usize);
let window = diff_lines_range(&diff, "txt", "TwoDark", iw as usize, 15, 20);
let actual = render_to_buffer(window, iw, ih, 0);
let reference = render_full_scrolled(full, iw, ih, 15, 0);
assert_buffers_match(&actual, &reference, iw, ih, "block-straddling window");
}
#[test]
fn diff_lines_side_by_side_range_matches_full_render_across_scroll_positions() {
let diff = synthetic_diff(2, 400);
let (iw, ih) = (72u16, 18u16);
let ext = "txt";
let full = diff_lines_side_by_side(&diff, ext, "TwoDark", iw as usize, 0);
let total = side_by_side_row_count(&diff, ext);
assert_eq!(
full.len(),
total,
"row_count はフル描画の行数と一致するはず"
);
assert!(
total < diff.len(),
"不揃いな remove/add ブロックの折り畳みで行数は diff.len() 未満になるはず(区別できているか): total={total} diff.len()={}",
diff.len()
);
let max_v = total.saturating_sub(ih as usize);
for scroll in [0usize, 20, max_v / 2, max_v] {
let window = diff_lines_side_by_side_range(
&diff,
ext,
"TwoDark",
iw as usize,
0,
scroll,
ih as usize,
);
let actual = render_to_buffer(window, iw, ih, 0);
let reference = render_full_scrolled(full.clone(), iw, ih, scroll as u16, 0);
assert_buffers_match(&actual, &reference, iw, ih, &format!("sbs scroll={scroll}"));
}
}
#[test]
fn diff_lines_side_by_side_range_matches_full_render_with_hscroll() {
let diff = synthetic_diff(1, 200);
let (iw, ih) = (50u16, 12u16);
let ext = "txt";
for hscroll in [0usize, 6] {
let full = diff_lines_side_by_side(&diff, ext, "TwoDark", iw as usize, hscroll);
let total = side_by_side_row_count(&diff, ext);
let max_v = total.saturating_sub(ih as usize);
let window = diff_lines_side_by_side_range(
&diff,
ext,
"TwoDark",
iw as usize,
hscroll,
max_v / 2,
ih as usize,
);
let actual = render_to_buffer(window, iw, ih, 0);
let reference = render_full_scrolled(full, iw, ih, (max_v / 2) as u16, 0);
assert_buffers_match(&actual, &reference, iw, ih, &format!("hscroll={hscroll}"));
}
}
#[test]
fn unified_max_hscroll_matches_the_widest_built_line() {
let diff = synthetic_diff(2, 60);
for width in [40usize, 80, 120] {
let lines = diff_lines(&diff, "txt", "TwoDark", width);
let expected = lines
.iter()
.map(|l| l.width())
.max()
.unwrap_or(0)
.saturating_sub(width);
let actual = unified_max_hscroll(&diff, width);
assert_eq!(actual, expected, "width={width}");
}
}
#[test]
fn unified_total_rows_is_diff_len_with_headers() {
let diff = synthetic_diff(3, 25);
assert_eq!(diff_lines(&diff, "txt", "TwoDark", 80).len(), diff.len());
}
}