use crate::event::Rect;
use crate::theme::Theme;
use crate::types::{Color, WidgetId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DiffRowKind {
Same,
Changed,
Removed,
Added,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiffRow {
pub left: Option<String>,
pub right: Option<String>,
pub kind: DiffRowKind,
}
pub fn row_colors(kind: DiffRowKind, theme: &Theme) -> (Color, Color, Color, Color) {
match kind {
DiffRowKind::Same => (
theme.muted_fg,
theme.background,
theme.muted_fg,
theme.background,
),
DiffRowKind::Changed => (
theme.git_deleted,
theme.diff_removed_bg,
theme.git_added,
theme.diff_added_bg,
),
DiffRowKind::Removed => (
theme.git_deleted,
theme.diff_removed_bg,
theme.muted_fg,
theme.diff_padding_bg,
),
DiffRowKind::Added => (
theme.muted_fg,
theme.diff_padding_bg,
theme.git_added,
theme.diff_added_bg,
),
}
}
pub fn unified_row_style(kind: DiffRowKind, theme: &Theme) -> (char, Color, Color) {
match kind {
DiffRowKind::Same => (' ', theme.muted_fg, theme.background),
DiffRowKind::Removed | DiffRowKind::Changed => {
('-', theme.git_deleted, theme.diff_removed_bg)
}
DiffRowKind::Added => ('+', theme.git_added, theme.diff_added_bg),
}
}
pub fn unified_row_text(row: &DiffRow) -> &str {
match row.kind {
DiffRowKind::Removed => row.left.as_deref().unwrap_or(""),
_ => row.right.as_deref().or(row.left.as_deref()).unwrap_or(""),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiffHunk {
pub left_start: usize,
pub right_start: usize,
pub rows: Vec<DiffRow>,
}
pub fn unified_hunk_header(hunk: &DiffHunk) -> String {
let left_count = hunk.rows.iter().filter(|r| r.left.is_some()).count();
let right_count = hunk.rows.iter().filter(|r| r.right.is_some()).count();
format!(
"@@ -{},{} +{},{} @@",
hunk.left_start, left_count, hunk.right_start, right_count
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum DiffMode {
#[default]
SideBySide,
Unified,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum DiffEditability {
#[default]
ReadOnly,
RightEditable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum DiffPane {
#[default]
Left,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DiffViewLayout {
pub visible_rows: usize,
pub total_rows: usize,
}
pub const DIFF_DIVIDER_W: f32 = 1.0;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DiffHeaderGeometry {
pub left: Rect,
pub right: Rect,
pub divider: Rect,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffLineContent {
UnifiedHeader {
hunk_idx: usize,
},
Row {
row_idx: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DiffDisplayLine {
pub bounds: Rect,
pub left: Option<Rect>,
pub right: Option<Rect>,
pub divider: Option<Rect>,
pub content: DiffLineContent,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DiffPaneGeometry {
pub left_w: f32,
pub right_w: f32,
pub divider_x: f32,
pub content_y: f32,
pub content_h: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DiffViewGeometry {
pub bounds: Rect,
pub header: Option<DiffHeaderGeometry>,
pub panes: Option<DiffPaneGeometry>,
pub visible_rows: usize,
pub total_rows: usize,
pub lines: Vec<DiffDisplayLine>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffViewHit {
Row {
row_idx: usize,
pane: Option<DiffPane>,
},
UnifiedHeader { hunk_idx: usize },
Empty,
}
impl DiffViewGeometry {
pub fn as_layout(&self) -> DiffViewLayout {
DiffViewLayout {
visible_rows: self.visible_rows,
total_rows: self.total_rows,
}
}
pub fn hit_test(&self, x: f32, y: f32) -> DiffViewHit {
for line in &self.lines {
let b = line.bounds;
if !(x >= b.x && x < b.x + b.width && y >= b.y && y < b.y + b.height) {
continue;
}
return match line.content {
DiffLineContent::UnifiedHeader { hunk_idx } => {
DiffViewHit::UnifiedHeader { hunk_idx }
}
DiffLineContent::Row { row_idx } => {
let pane = match (line.left, line.right) {
(Some(left), _) if x >= left.x && x < left.x + left.width => {
Some(DiffPane::Left)
}
(_, Some(right)) if x >= right.x && x < right.x + right.width => {
Some(DiffPane::Right)
}
_ => None,
};
DiffViewHit::Row { row_idx, pane }
}
};
}
DiffViewHit::Empty
}
}
fn scroll_window(scroll_offset: usize, visible_rows: usize, total: usize) -> (usize, usize) {
let start = scroll_offset.min(total.saturating_sub(1));
let end = (start + visible_rows).min(total);
(start, end)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiffView {
pub id: WidgetId,
pub left: String,
pub right: String,
pub left_label: Option<String>,
pub right_label: Option<String>,
pub hunks: Vec<DiffHunk>,
pub mode: DiffMode,
pub editability: DiffEditability,
pub scroll_offset: usize,
pub focused_pane: DiffPane,
pub has_focus: bool,
}
impl DiffView {
pub fn total_rows(&self) -> usize {
self.hunks.iter().map(|h| h.rows.len()).sum()
}
pub fn flat_rows(&self) -> Vec<&DiffRow> {
self.hunks.iter().flat_map(|h| h.rows.iter()).collect()
}
pub fn layout(&self, viewport: Rect, line_height: f32) -> DiffViewGeometry {
if viewport.width <= 0.0 || viewport.height <= 0.0 || line_height <= 0.0 {
return DiffViewGeometry {
bounds: viewport,
header: None,
panes: None,
visible_rows: 0,
total_rows: self.total_rows(),
lines: Vec::new(),
};
}
match self.mode {
DiffMode::SideBySide => self.layout_side_by_side(viewport, line_height),
DiffMode::Unified => self.layout_unified(viewport, line_height),
}
}
fn layout_side_by_side(&self, viewport: Rect, line_height: f32) -> DiffViewGeometry {
let has_header = self.left_label.is_some() || self.right_label.is_some();
let header_h = if has_header { line_height } else { 0.0 };
let left_w = ((viewport.width - DIFF_DIVIDER_W) / 2.0).floor();
let right_w = (viewport.width - DIFF_DIVIDER_W - left_w).max(0.0);
let divider_x = viewport.x + left_w;
let header = has_header.then(|| DiffHeaderGeometry {
left: Rect::new(viewport.x, viewport.y, left_w, header_h),
right: Rect::new(divider_x + DIFF_DIVIDER_W, viewport.y, right_w, header_h),
divider: Rect::new(divider_x, viewport.y, DIFF_DIVIDER_W, header_h),
});
let content_y = viewport.y + header_h;
let content_h = (viewport.height - header_h).max(0.0);
let visible_rows = (content_h / line_height).floor() as usize;
let total_rows = self.total_rows();
let (start, end) = scroll_window(self.scroll_offset, visible_rows, total_rows);
let mut lines = Vec::with_capacity(end.saturating_sub(start));
for (i, row_idx) in (start..end).enumerate() {
let row_y = content_y + i as f32 * line_height;
lines.push(DiffDisplayLine {
bounds: Rect::new(viewport.x, row_y, viewport.width, line_height),
left: Some(Rect::new(viewport.x, row_y, left_w, line_height)),
right: Some(Rect::new(
divider_x + DIFF_DIVIDER_W,
row_y,
right_w,
line_height,
)),
divider: Some(Rect::new(divider_x, row_y, DIFF_DIVIDER_W, line_height)),
content: DiffLineContent::Row { row_idx },
});
}
DiffViewGeometry {
bounds: viewport,
header,
panes: Some(DiffPaneGeometry {
left_w,
right_w,
divider_x,
content_y,
content_h,
}),
visible_rows,
total_rows,
lines,
}
}
fn layout_unified(&self, viewport: Rect, line_height: f32) -> DiffViewGeometry {
let mut sequence: Vec<DiffLineContent> = Vec::new();
let mut row_idx = 0usize;
for (hunk_idx, hunk) in self.hunks.iter().enumerate() {
sequence.push(DiffLineContent::UnifiedHeader { hunk_idx });
for _ in &hunk.rows {
sequence.push(DiffLineContent::Row { row_idx });
row_idx += 1;
}
}
let total_display = sequence.len();
let visible_rows = (viewport.height / line_height).floor() as usize;
let (start, end) = scroll_window(self.scroll_offset, visible_rows, total_display);
let mut lines = Vec::with_capacity(end.saturating_sub(start));
for (i, content) in sequence[start..end].iter().enumerate() {
let row_y = viewport.y + i as f32 * line_height;
lines.push(DiffDisplayLine {
bounds: Rect::new(viewport.x, row_y, viewport.width, line_height),
left: None,
right: None,
divider: None,
content: *content,
});
}
DiffViewGeometry {
bounds: viewport,
header: None,
panes: None,
visible_rows,
total_rows: total_display,
lines,
}
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{
row_colors, unified_hunk_header, unified_row_style, unified_row_text, DiffLineContent,
DiffMode, DiffView, DiffViewGeometry, DiffViewLayout,
};
use crate::event::Rect;
use crate::paint_surface::PaintSurface;
use crate::theme::Theme;
use crate::types::Color;
const TEXT_PAD: f32 = 4.0;
const UNIFIED_PAD: f32 = 2.0;
pub(crate) fn paint(
view: &DiffView,
surface: &mut dyn PaintSurface,
theme: &Theme,
rect: Rect,
line_height: f32,
) -> DiffViewLayout {
let geometry = view.layout(rect, line_height);
if rect.width <= 0.0 || rect.height <= 0.0 || line_height <= 0.0 {
return geometry.as_layout();
}
surface.surface_fill_rect(rect, theme.background);
match view.mode {
DiffMode::SideBySide => paint_side_by_side(view, surface, theme, &geometry),
DiffMode::Unified => paint_unified(view, surface, theme, &geometry),
}
geometry.as_layout()
}
fn paint_clipped_text(
surface: &mut dyn PaintSurface,
rect: Rect,
pad: f32,
text: &str,
color: Color,
) {
if rect.width <= 0.0 || rect.height <= 0.0 || text.is_empty() {
return;
}
surface.surface_push_clip(rect);
surface.surface_draw_text_run(
Rect::new(
rect.x + pad,
rect.y,
(rect.width - pad).max(0.0),
rect.height,
),
text,
color,
);
surface.surface_pop_clip();
}
fn paint_side_by_side(
view: &DiffView,
surface: &mut dyn PaintSurface,
theme: &Theme,
geometry: &DiffViewGeometry,
) {
let flat = view.flat_rows();
if let Some(header) = &geometry.header {
let strip = Rect::new(
header.left.x,
header.left.y,
header.left.width + header.divider.width + header.right.width,
header.left.height,
);
surface.surface_fill_rect(strip, theme.header_bg);
surface.surface_fill_rect(header.divider, theme.border_fg);
if let Some(label) = &view.left_label {
paint_clipped_text(surface, header.left, TEXT_PAD, label, theme.header_fg);
}
if let Some(label) = &view.right_label {
paint_clipped_text(surface, header.right, TEXT_PAD, label, theme.header_fg);
}
}
for line in &geometry.lines {
let DiffLineContent::Row { row_idx } = line.content else {
continue;
};
let row = flat[row_idx];
let (left_fg, left_bg, right_fg, right_bg) = row_colors(row.kind, theme);
let left_r = line.left.expect("side-by-side row has left bounds");
let right_r = line.right.expect("side-by-side row has right bounds");
let divider_r = line.divider.expect("side-by-side row has divider bounds");
surface.surface_fill_rect(left_r, left_bg);
surface.surface_fill_rect(right_r, right_bg);
surface.surface_fill_rect(divider_r, theme.border_fg);
if let Some(text) = &row.left {
paint_clipped_text(surface, left_r, TEXT_PAD, text, left_fg);
}
if let Some(text) = &row.right {
paint_clipped_text(surface, right_r, TEXT_PAD, text, right_fg);
}
}
}
fn paint_unified(
view: &DiffView,
surface: &mut dyn PaintSurface,
theme: &Theme,
geometry: &DiffViewGeometry,
) {
let flat = view.flat_rows();
for line in &geometry.lines {
let bounds = line.bounds;
match line.content {
DiffLineContent::UnifiedHeader { hunk_idx } => {
let header_text = unified_hunk_header(&view.hunks[hunk_idx]);
surface.surface_fill_rect(bounds, theme.background);
paint_clipped_text(surface, bounds, UNIFIED_PAD, &header_text, theme.accent_fg);
}
DiffLineContent::Row { row_idx } => {
let row = flat[row_idx];
let (prefix, fg, bg) = unified_row_style(row.kind, theme);
surface.surface_fill_rect(bounds, bg);
let prefix_str = prefix.to_string();
let (prefix_w, _) = surface.surface_measure_text(&prefix_str);
surface.surface_draw_text_run(
Rect::new(
bounds.x + UNIFIED_PAD,
bounds.y,
prefix_w.max(1.0),
bounds.height,
),
&prefix_str,
fg,
);
let text = unified_row_text(row);
let text_x = bounds.x + UNIFIED_PAD + prefix_w + TEXT_PAD;
let text_rect = Rect::new(
text_x,
bounds.y,
(bounds.x + bounds.width - text_x).max(0.0),
bounds.height,
);
paint_clipped_text(surface, text_rect, 0.0, text, fg);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::event::Viewport;
use crate::primitives::diff_view::{DiffHunk, DiffRow, DiffRowKind};
use crate::types::WidgetId;
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
text_runs: Vec<(Rect, String, Color)>,
clip_pushes: Vec<Rect>,
clip_pops: usize,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> Viewport {
Viewport::new(200.0, 100.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
16.0
}
fn surface_char_width(&self) -> f32 {
8.0
}
fn surface_measure_text(&self, text: &str) -> (f32, f32) {
(text.chars().count() as f32 * 8.0, 14.0)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: Color) {
self.fills.push((rect, color));
}
fn surface_stroke_rect(&mut self, _rect: Rect, _color: Color, _stroke_width: f32) {}
fn surface_stroke_rounded_rect(
&mut self,
_rect: Rect,
_radius: f32,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_draw_text_run(&mut self, rect: Rect, text: &str, color: Color) {
self.text_runs.push((rect, text.to_string(), color));
}
fn surface_draw_line(
&mut self,
_from: crate::Point,
_to: crate::Point,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_push_clip(&mut self, rect: Rect) {
self.clip_pushes.push(rect);
}
fn surface_pop_clip(&mut self) {
self.clip_pops += 1;
}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
fn sample_view(mode: DiffMode) -> DiffView {
DiffView {
id: WidgetId::new("diff"),
left: "one\ntwo\n".into(),
right: "one\nTWO\n".into(),
left_label: None,
right_label: None,
hunks: vec![DiffHunk {
left_start: 1,
right_start: 1,
rows: vec![
DiffRow {
left: Some("one".into()),
right: Some("one".into()),
kind: DiffRowKind::Same,
},
DiffRow {
left: Some("two".into()),
right: Some("TWO".into()),
kind: DiffRowKind::Changed,
},
],
}],
mode,
editability: Default::default(),
scroll_offset: 0,
focused_pane: Default::default(),
has_focus: false,
}
}
#[test]
fn zero_size_rect_paints_nothing() {
let view = sample_view(DiffMode::SideBySide);
let mut surface = RecordingSurface::default();
let layout = paint(
&view,
&mut surface,
&Theme::default(),
Rect::new(0.0, 0.0, 0.0, 0.0),
16.0,
);
assert_eq!(layout.visible_rows, 0);
assert!(surface.fills.is_empty());
assert!(surface.text_runs.is_empty());
}
#[test]
fn side_by_side_paints_row_backgrounds_and_divider() {
let view = sample_view(DiffMode::SideBySide);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&view,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 100.0, 32.0),
16.0,
);
assert_eq!(surface.fills.len(), 1 + 3 * 2);
}
#[test]
fn row_text_paints_clipped_and_top_aligned() {
let view = sample_view(DiffMode::SideBySide);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&view,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 100.0, 32.0),
16.0,
);
assert_eq!(surface.clip_pushes.len(), surface.clip_pops);
assert!(!surface.text_runs.is_empty());
let (rect, text, _) = &surface.text_runs[0];
assert_eq!(text, "one");
assert_eq!(
rect.y, 0.0,
"row 0 text must be top-aligned at the row's own y"
);
}
#[test]
fn header_label_paints_clipped_not_ellipsized() {
let mut view = sample_view(DiffMode::SideBySide);
view.left_label = Some("a/very/long/path/that/would/overflow.rs".into());
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let layout = paint(
&view,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 40.0, 32.0),
16.0,
);
assert_eq!(layout.visible_rows, 1, "header steals one row");
assert_eq!(surface.clip_pushes.len(), surface.clip_pops);
assert!(
surface.clip_pushes.iter().any(|r| r.height == 16.0),
"header label clip uses the header strip's own height"
);
}
#[test]
fn unified_mode_paints_hunk_header_and_prefix() {
let view = sample_view(DiffMode::Unified);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let layout = paint(
&view,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 100.0, 100.0),
16.0,
);
assert_eq!(layout.total_rows, view.total_rows() + view.hunks.len());
let header_text = surface
.text_runs
.iter()
.find(|(_, t, _)| t.starts_with("@@"))
.map(|(_, t, _)| t.clone())
.expect("hunk header text painted");
assert_eq!(header_text, "@@ -1,2 +1,2 @@");
assert!(surface.text_runs.iter().any(|(_, t, _)| t == "-"));
}
#[test]
fn padding_row_paints_no_clip_for_missing_side() {
let view = DiffView {
id: WidgetId::new("diff"),
left: String::new(),
right: String::new(),
left_label: None,
right_label: None,
hunks: vec![DiffHunk {
left_start: 1,
right_start: 1,
rows: vec![DiffRow {
left: Some("x".into()),
right: None,
kind: DiffRowKind::Removed,
}],
}],
mode: DiffMode::SideBySide,
editability: Default::default(),
scroll_offset: 0,
focused_pane: Default::default(),
has_focus: false,
};
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&view,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 100.0, 16.0),
16.0,
);
assert_eq!(surface.text_runs.len(), 1);
assert_eq!(surface.text_runs[0].1, "x");
assert_eq!(surface.clip_pushes.len(), 1);
assert_eq!(surface.clip_pops, 1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::WidgetId;
#[test]
fn diff_view_field_defaults() {
let view = DiffView {
id: WidgetId::new("test"),
left: String::new(),
right: String::new(),
left_label: None,
right_label: None,
hunks: vec![],
mode: DiffMode::default(),
editability: DiffEditability::default(),
scroll_offset: 0,
focused_pane: DiffPane::default(),
has_focus: false,
};
assert_eq!(view.mode, DiffMode::SideBySide);
assert_eq!(view.editability, DiffEditability::ReadOnly);
assert_eq!(view.focused_pane, DiffPane::Left);
assert_eq!(view.scroll_offset, 0);
assert!(!view.has_focus);
assert_eq!(view.total_rows(), 0);
}
#[test]
fn total_rows_sums_hunks() {
let make_hunk = |n: usize| DiffHunk {
left_start: 1,
right_start: 1,
rows: (0..n)
.map(|_| DiffRow {
left: Some("x".into()),
right: Some("x".into()),
kind: DiffRowKind::Same,
})
.collect(),
};
let view = DiffView {
id: WidgetId::new("t"),
left: String::new(),
right: String::new(),
left_label: None,
right_label: None,
hunks: vec![make_hunk(3), make_hunk(5)],
mode: DiffMode::default(),
editability: DiffEditability::default(),
scroll_offset: 0,
focused_pane: DiffPane::default(),
has_focus: false,
};
assert_eq!(view.total_rows(), 8);
}
#[test]
fn acceptance_criterion_constructor_compiles_and_runs() {
use crate::diff::compute_hunks;
let left = "a\nb\n";
let right = "a\nc\n";
let view = DiffView {
id: WidgetId::new("acceptance"),
left: left.into(),
right: right.into(),
left_label: None,
right_label: None,
hunks: compute_hunks(left, right),
mode: DiffMode::SideBySide,
editability: DiffEditability::ReadOnly,
scroll_offset: 0,
focused_pane: DiffPane::Left,
has_focus: false,
};
assert!(!view.hunks.is_empty(), "expected at least one hunk");
assert_eq!(view.left, "a\nb\n");
assert_eq!(view.right, "a\nc\n");
}
fn two_hunk_view(mode: DiffMode) -> DiffView {
DiffView {
id: WidgetId::new("layout-test"),
left: String::new(),
right: String::new(),
left_label: None,
right_label: None,
hunks: vec![
DiffHunk {
left_start: 1,
right_start: 1,
rows: vec![
DiffRow {
left: Some("alpha".into()),
right: Some("ALPHA".into()),
kind: DiffRowKind::Changed,
},
DiffRow {
left: Some("beta".into()),
right: Some("beta".into()),
kind: DiffRowKind::Same,
},
],
},
DiffHunk {
left_start: 10,
right_start: 10,
rows: vec![
DiffRow {
left: Some("gamma".into()),
right: None,
kind: DiffRowKind::Removed,
},
DiffRow {
left: None,
right: Some("delta".into()),
kind: DiffRowKind::Added,
},
],
},
],
mode,
editability: DiffEditability::ReadOnly,
scroll_offset: 0,
focused_pane: DiffPane::Left,
has_focus: false,
}
}
#[test]
fn flat_rows_concatenates_every_hunk() {
let view = two_hunk_view(DiffMode::SideBySide);
let flat = view.flat_rows();
assert_eq!(flat.len(), 4);
assert_eq!(flat[0].left.as_deref(), Some("alpha"));
assert_eq!(flat[3].right.as_deref(), Some("delta"));
}
#[test]
fn zero_size_viewport_returns_empty_geometry() {
let view = two_hunk_view(DiffMode::SideBySide);
let geometry = view.layout(Rect::new(0.0, 0.0, 0.0, 10.0), 1.0);
assert_eq!(geometry.visible_rows, 0);
assert_eq!(geometry.total_rows, view.total_rows());
assert!(geometry.lines.is_empty());
let geometry = view.layout(Rect::new(0.0, 0.0, 10.0, 10.0), 0.0);
assert_eq!(geometry.visible_rows, 0);
}
#[test]
fn side_by_side_panes_reconstruct_viewport_width() {
let view = two_hunk_view(DiffMode::SideBySide);
let viewport = Rect::new(5.0, 0.0, 41.0, 10.0);
let geometry = view.layout(viewport, 1.0);
let panes = geometry.panes.expect("side-by-side mode has panes");
assert_eq!(
panes.left_w + DIFF_DIVIDER_W + panes.right_w,
viewport.width
);
assert_eq!(panes.divider_x, viewport.x + panes.left_w);
}
#[test]
fn header_present_only_when_a_label_is_set() {
let mut view = two_hunk_view(DiffMode::SideBySide);
let viewport = Rect::new(0.0, 0.0, 40.0, 10.0);
let no_header = view.layout(viewport, 1.0);
assert!(no_header.header.is_none());
assert_eq!(no_header.visible_rows, 10);
view.left_label = Some("a/main.rs".into());
let with_header = view.layout(viewport, 1.0);
let header = with_header.header.expect("label set implies a header");
assert_eq!(header.left.height, 1.0);
assert_eq!(with_header.visible_rows, 9, "header steals one row");
}
#[test]
fn side_by_side_lines_respect_scroll_offset() {
let mut view = two_hunk_view(DiffMode::SideBySide);
view.scroll_offset = 1;
let geometry = view.layout(Rect::new(0.0, 0.0, 40.0, 2.0), 1.0);
assert_eq!(geometry.lines.len(), 2);
assert_eq!(
geometry.lines[0].content,
DiffLineContent::Row { row_idx: 1 }
);
assert_eq!(
geometry.lines[1].content,
DiffLineContent::Row { row_idx: 2 }
);
}
#[test]
fn unified_lines_interleave_headers_with_rows() {
let view = two_hunk_view(DiffMode::Unified);
let geometry = view.layout(Rect::new(0.0, 0.0, 40.0, 100.0), 1.0);
assert_eq!(geometry.total_rows, 6);
assert_eq!(geometry.lines.len(), 6);
assert_eq!(
geometry.lines[0].content,
DiffLineContent::UnifiedHeader { hunk_idx: 0 }
);
assert_eq!(
geometry.lines[1].content,
DiffLineContent::Row { row_idx: 0 }
);
assert_eq!(
geometry.lines[2].content,
DiffLineContent::Row { row_idx: 1 }
);
assert_eq!(
geometry.lines[3].content,
DiffLineContent::UnifiedHeader { hunk_idx: 1 }
);
assert_eq!(
geometry.lines[4].content,
DiffLineContent::Row { row_idx: 2 }
);
assert_eq!(
geometry.lines[5].content,
DiffLineContent::Row { row_idx: 3 }
);
}
#[test]
fn row_colors_same_uses_uniform_background() {
let theme = Theme::default();
let (_, left_bg, _, right_bg) = row_colors(DiffRowKind::Same, &theme);
assert_eq!(left_bg, theme.background);
assert_eq!(right_bg, theme.background);
}
#[test]
fn unified_row_style_prefixes() {
let theme = Theme::default();
assert_eq!(unified_row_style(DiffRowKind::Removed, &theme).0, '-');
assert_eq!(unified_row_style(DiffRowKind::Changed, &theme).0, '-');
assert_eq!(unified_row_style(DiffRowKind::Added, &theme).0, '+');
assert_eq!(unified_row_style(DiffRowKind::Same, &theme).0, ' ');
}
#[test]
fn unified_row_text_prefers_left_only_for_removed() {
let removed = DiffRow {
left: Some("old".into()),
right: None,
kind: DiffRowKind::Removed,
};
assert_eq!(unified_row_text(&removed), "old");
let changed = DiffRow {
left: Some("old".into()),
right: Some("new".into()),
kind: DiffRowKind::Changed,
};
assert_eq!(unified_row_text(&changed), "new");
}
#[test]
fn unified_hunk_header_excludes_padding_from_counts() {
let hunk = DiffHunk {
left_start: 5,
right_start: 7,
rows: vec![
DiffRow {
left: Some("old1".into()),
right: Some("new1".into()),
kind: DiffRowKind::Changed,
},
DiffRow {
left: Some("old2".into()),
right: None,
kind: DiffRowKind::Removed,
},
DiffRow {
left: None,
right: Some("new3".into()),
kind: DiffRowKind::Added,
},
],
};
assert_eq!(unified_hunk_header(&hunk), "@@ -5,2 +7,2 @@");
}
#[test]
fn hit_test_side_by_side_left_pane_returns_row_and_left_pane() {
let view = two_hunk_view(DiffMode::SideBySide);
let geometry = view.layout(Rect::new(0.0, 0.0, 40.0, 10.0), 1.0);
match geometry.hit_test(2.0, 0.5) {
DiffViewHit::Row { row_idx, pane } => {
assert_eq!(row_idx, 0);
assert_eq!(pane, Some(DiffPane::Left));
}
other => panic!("expected Row hit, got {other:?}"),
}
}
#[test]
fn hit_test_side_by_side_right_pane_returns_right() {
let view = two_hunk_view(DiffMode::SideBySide);
let geometry = view.layout(Rect::new(0.0, 0.0, 40.0, 10.0), 1.0);
let panes = geometry.panes.expect("side-by-side has panes");
let x_in_right = panes.divider_x + DIFF_DIVIDER_W + 1.0;
match geometry.hit_test(x_in_right, 0.5) {
DiffViewHit::Row { row_idx, pane } => {
assert_eq!(row_idx, 0);
assert_eq!(pane, Some(DiffPane::Right));
}
other => panic!("expected Row hit, got {other:?}"),
}
}
#[test]
fn hit_test_unified_header_returns_hunk_idx() {
let view = two_hunk_view(DiffMode::Unified);
let geometry = view.layout(Rect::new(0.0, 0.0, 40.0, 10.0), 1.0);
assert_eq!(
geometry.hit_test(2.0, 0.5),
DiffViewHit::UnifiedHeader { hunk_idx: 0 }
);
assert_eq!(
geometry.hit_test(2.0, 3.5),
DiffViewHit::UnifiedHeader { hunk_idx: 1 }
);
}
#[test]
fn hit_test_unified_row_has_no_pane() {
let view = two_hunk_view(DiffMode::Unified);
let geometry = view.layout(Rect::new(0.0, 0.0, 40.0, 10.0), 1.0);
match geometry.hit_test(2.0, 1.5) {
DiffViewHit::Row { row_idx, pane } => {
assert_eq!(row_idx, 0);
assert_eq!(pane, None);
}
other => panic!("expected Row hit, got {other:?}"),
}
}
#[test]
fn hit_test_outside_all_lines_is_empty() {
let view = two_hunk_view(DiffMode::SideBySide);
let geometry = view.layout(Rect::new(0.0, 0.0, 40.0, 10.0), 1.0);
assert_eq!(geometry.hit_test(2.0, 99.0), DiffViewHit::Empty);
}
}