use crate::render::{Cell, Modifier};
use crate::style::Color;
use crate::utils::{char_width, truncate_to_width};
use crate::widget::theme::{DISABLED_FG, SEPARATOR_COLOR};
use crate::widget::traits::{RenderContext, View, WidgetProps};
use crate::{impl_props_builders, impl_styled_view};
use similar::{ChangeTag, TextDiff};
struct LineLayout {
x: u16,
y: u16,
line_num_width: u16,
content_width: usize,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DiffMode {
#[default]
Split,
Unified,
Inline,
}
#[derive(Clone, Debug)]
pub struct DiffLine {
pub left_num: Option<usize>,
pub right_num: Option<usize>,
pub left: String,
pub right: String,
pub change: ChangeType,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChangeType {
Equal,
Removed,
Added,
Modified,
}
#[derive(Clone, Debug)]
pub struct DiffColors {
pub added_bg: Color,
pub added_fg: Color,
pub removed_bg: Color,
pub removed_fg: Color,
pub modified_bg: Color,
pub line_number: Color,
pub separator: Color,
pub header_bg: Color,
}
impl Default for DiffColors {
fn default() -> Self {
Self {
added_bg: Color::rgb(30, 60, 30),
added_fg: Color::rgb(150, 255, 150),
removed_bg: Color::rgb(60, 30, 30),
removed_fg: Color::rgb(255, 150, 150),
modified_bg: Color::rgb(60, 60, 30),
line_number: DISABLED_FG,
separator: SEPARATOR_COLOR,
header_bg: Color::rgb(40, 40, 60),
}
}
}
impl DiffColors {
pub fn github() -> Self {
Self {
added_bg: Color::rgb(35, 134, 54),
added_fg: Color::WHITE,
removed_bg: Color::rgb(218, 54, 51),
removed_fg: Color::WHITE,
modified_bg: Color::rgb(210, 153, 34),
line_number: Color::rgb(140, 140, 140),
separator: Color::rgb(48, 54, 61),
header_bg: Color::rgb(22, 27, 34),
}
}
}
pub struct DiffViewer {
left_content: String,
right_content: String,
left_name: String,
right_name: String,
mode: DiffMode,
colors: DiffColors,
show_line_numbers: bool,
scroll: usize,
context_lines: usize,
diff_lines: Vec<DiffLine>,
props: WidgetProps,
}
impl DiffViewer {
pub fn new() -> Self {
Self {
left_content: String::new(),
right_content: String::new(),
left_name: "Original".to_string(),
right_name: "Modified".to_string(),
mode: DiffMode::default(),
colors: DiffColors::default(),
show_line_numbers: true,
scroll: 0,
context_lines: 3,
diff_lines: Vec::new(),
props: WidgetProps::new(),
}
}
pub fn left(mut self, content: impl Into<String>) -> Self {
self.left_content = content.into();
self.compute_diff();
self
}
pub fn right(mut self, content: impl Into<String>) -> Self {
self.right_content = content.into();
self.compute_diff();
self
}
pub fn left_name(mut self, name: impl Into<String>) -> Self {
self.left_name = name.into();
self
}
pub fn right_name(mut self, name: impl Into<String>) -> Self {
self.right_name = name.into();
self
}
pub fn compare(mut self, left: impl Into<String>, right: impl Into<String>) -> Self {
self.left_content = left.into();
self.right_content = right.into();
self.compute_diff();
self
}
pub fn mode(mut self, mode: DiffMode) -> Self {
self.mode = mode;
self
}
pub fn colors(mut self, colors: DiffColors) -> Self {
self.colors = colors;
self
}
pub fn line_numbers(mut self, show: bool) -> Self {
self.show_line_numbers = show;
self
}
pub fn context(mut self, lines: usize) -> Self {
self.context_lines = lines;
self
}
pub fn set_scroll(&mut self, scroll: usize) {
self.scroll = scroll.min(self.diff_lines.len().saturating_sub(1));
}
pub fn scroll_down(&mut self, amount: usize) {
self.set_scroll(self.scroll.saturating_add(amount));
}
pub fn scroll_up(&mut self, amount: usize) {
self.scroll = self.scroll.saturating_sub(amount);
}
fn compute_diff(&mut self) {
let diff = TextDiff::from_lines(&self.left_content, &self.right_content);
self.diff_lines.clear();
let mut left_num = 0usize;
let mut right_num = 0usize;
for change in diff.iter_all_changes() {
let (left_n, right_n, change_type) = match change.tag() {
ChangeTag::Equal => {
left_num += 1;
right_num += 1;
(Some(left_num), Some(right_num), ChangeType::Equal)
}
ChangeTag::Delete => {
left_num += 1;
(Some(left_num), None, ChangeType::Removed)
}
ChangeTag::Insert => {
right_num += 1;
(None, Some(right_num), ChangeType::Added)
}
};
let content = change.value().trim_end_matches('\n').to_string();
self.diff_lines.push(DiffLine {
left_num: left_n,
right_num: right_n,
left: if change.tag() != ChangeTag::Insert {
content.clone()
} else {
String::new()
},
right: if change.tag() != ChangeTag::Delete {
content
} else {
String::new()
},
change: change_type,
});
}
}
pub fn change_count(&self) -> usize {
self.diff_lines
.iter()
.filter(|l| l.change != ChangeType::Equal)
.count()
}
pub fn line_count(&self) -> usize {
self.diff_lines.len()
}
fn render_split(&self, ctx: &mut RenderContext) {
let area = ctx.area;
if area.width < 10 || area.height < 3 {
return;
}
let half_width = (area.width / 2).saturating_sub(1);
let line_num_width = if self.show_line_numbers { 5 } else { 0 };
let content_width = half_width.saturating_sub(line_num_width) as usize;
self.render_header(ctx, half_width);
let visible_lines = (area.height - 1) as usize;
for (i, line) in self
.diff_lines
.iter()
.skip(self.scroll)
.take(visible_lines)
.enumerate()
{
let y = 1 + i as u16;
let left_layout = LineLayout {
x: 0,
y,
line_num_width,
content_width,
};
self.render_line_half(ctx, line, true, &left_layout);
let mut sep = Cell::new('│');
sep.fg = Some(self.colors.separator);
ctx.set(half_width, y, sep);
let right_layout = LineLayout {
x: half_width + 1,
y,
line_num_width,
content_width,
};
self.render_line_half(ctx, line, false, &right_layout);
}
}
fn render_line_half(
&self,
ctx: &mut RenderContext,
line: &DiffLine,
is_left: bool,
layout: &LineLayout,
) {
let LineLayout {
x,
y,
line_num_width,
content_width,
} = *layout;
let (content, line_num, bg) = if is_left {
(
&line.left,
line.left_num,
match line.change {
ChangeType::Removed => Some(self.colors.removed_bg),
ChangeType::Modified => Some(self.colors.modified_bg),
_ => None,
},
)
} else {
(
&line.right,
line.right_num,
match line.change {
ChangeType::Added => Some(self.colors.added_bg),
ChangeType::Modified => Some(self.colors.modified_bg),
_ => None,
},
)
};
if self.show_line_numbers {
let num_str = line_num
.map(|n| format!("{:>4}", n))
.unwrap_or_else(|| " ".to_string());
for (i, ch) in num_str.chars().enumerate() {
if i as u16 >= line_num_width {
break;
}
let mut cell = Cell::new(ch);
cell.fg = Some(self.colors.line_number);
cell.bg = bg;
ctx.set(x + i as u16, y, cell);
}
}
let fg = match line.change {
ChangeType::Added => Some(self.colors.added_fg),
ChangeType::Removed => Some(self.colors.removed_fg),
_ => None,
};
let truncated = truncate_to_width(content, content_width);
let mut dx: u16 = 0;
for ch in truncated.chars() {
let cw = char_width(ch) as u16;
if dx + cw > content_width as u16 {
break;
}
let mut cell = Cell::new(ch);
cell.fg = fg;
cell.bg = bg;
ctx.set(x + line_num_width + dx, y, cell);
dx += cw;
}
for i in dx..(content_width as u16) {
let mut cell = Cell::new(' ');
cell.bg = bg;
ctx.set(x + line_num_width + i, y, cell);
}
}
fn render_header(&self, ctx: &mut RenderContext, half_width: u16) {
for (i, ch) in self.left_name.chars().enumerate() {
if i as u16 >= half_width {
break;
}
let mut cell = Cell::new(ch);
cell.bg = Some(self.colors.header_bg);
cell.modifier = Modifier::BOLD;
ctx.set(i as u16, 0, cell);
}
for i in self.left_name.len()..half_width as usize {
let mut cell = Cell::new(' ');
cell.bg = Some(self.colors.header_bg);
ctx.set(i as u16, 0, cell);
}
let mut sep = Cell::new('│');
sep.fg = Some(self.colors.separator);
sep.bg = Some(self.colors.header_bg);
ctx.set(half_width, 0, sep);
for (i, ch) in self.right_name.chars().enumerate() {
if i as u16 >= half_width {
break;
}
let mut cell = Cell::new(ch);
cell.bg = Some(self.colors.header_bg);
cell.modifier = Modifier::BOLD;
ctx.set(half_width + 1 + i as u16, 0, cell);
}
for i in self.right_name.len()..half_width as usize {
let mut cell = Cell::new(' ');
cell.bg = Some(self.colors.header_bg);
ctx.set(half_width + 1 + i as u16, 0, cell);
}
}
fn render_unified(&self, ctx: &mut RenderContext) {
let area = ctx.area;
let line_num_width = if self.show_line_numbers { 10u16 } else { 0 };
let content_width = area.width.saturating_sub(line_num_width + 1) as usize;
let visible_lines = area.height as usize;
for (i, line) in self
.diff_lines
.iter()
.skip(self.scroll)
.take(visible_lines)
.enumerate()
{
let y = i as u16;
if self.show_line_numbers {
let num_str = format!(
"{:>4}:{:<4}",
line.left_num.map(|n| n.to_string()).unwrap_or_default(),
line.right_num.map(|n| n.to_string()).unwrap_or_default()
);
for (j, ch) in num_str.chars().enumerate() {
if j as u16 >= line_num_width {
break;
}
let mut cell = Cell::new(ch);
cell.fg = Some(self.colors.line_number);
ctx.set(j as u16, y, cell);
}
}
let (indicator, fg, bg) = match line.change {
ChangeType::Added => ('+', self.colors.added_fg, self.colors.added_bg),
ChangeType::Removed => ('-', self.colors.removed_fg, self.colors.removed_bg),
ChangeType::Modified => ('~', self.colors.added_fg, self.colors.modified_bg),
ChangeType::Equal => (' ', Color::WHITE, Color::default()),
};
let mut ind_cell = Cell::new(indicator);
ind_cell.fg = Some(fg);
ind_cell.bg = Some(bg);
ctx.set(line_num_width, y, ind_cell);
let content = if !line.right.is_empty() {
&line.right
} else {
&line.left
};
let truncated = truncate_to_width(content, content_width);
let mut dx: u16 = 0;
for ch in truncated.chars() {
let cw = char_width(ch) as u16;
if dx + cw > content_width as u16 {
break;
}
let mut cell = Cell::new(ch);
cell.fg = Some(fg);
cell.bg = Some(bg);
ctx.set(line_num_width + 1 + dx, y, cell);
dx += cw;
}
}
}
}
impl Default for DiffViewer {
fn default() -> Self {
Self::new()
}
}
impl View for DiffViewer {
crate::impl_view_meta!("DiffViewer");
fn render(&self, ctx: &mut RenderContext) {
match self.mode {
DiffMode::Split => self.render_split(ctx),
DiffMode::Unified | DiffMode::Inline => self.render_unified(ctx),
}
}
}
impl_styled_view!(DiffViewer);
impl_props_builders!(DiffViewer);
pub fn diff_viewer() -> DiffViewer {
DiffViewer::new()
}
pub fn diff(left: impl Into<String>, right: impl Into<String>) -> DiffViewer {
DiffViewer::new().compare(left, right)
}