1use similar::{ChangeTag, TextDiff};
7
8use crate::buffer::ScreenBuffer;
9use crate::cell::Cell;
10use crate::event::{Event, KeyCode, KeyEvent};
11use crate::geometry::Rect;
12use crate::style::Style;
13use crate::text::truncate_to_display_width;
14
15use super::{BorderStyle, EventResult, InteractiveWidget, Widget};
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum DiffMode {
20 Unified,
22 SideBySide,
24}
25
26#[derive(Clone, Debug)]
28struct DiffLine {
29 tag: ChangeTag,
31 text: String,
33}
34
35#[derive(Clone, Debug)]
37struct SideBySidePair {
38 left: Option<DiffLine>,
40 right: Option<DiffLine>,
42}
43
44pub struct DiffView {
49 old_text: String,
51 new_text: String,
53 mode: DiffMode,
55 scroll_offset: usize,
57 unchanged_style: Style,
59 added_style: Style,
61 removed_style: Style,
63 border: BorderStyle,
65 unified_lines: Vec<DiffLine>,
67 sbs_pairs: Vec<SideBySidePair>,
69}
70
71impl DiffView {
72 pub fn new(old_text: &str, new_text: &str) -> Self {
74 let mut view = Self {
75 old_text: old_text.to_string(),
76 new_text: new_text.to_string(),
77 mode: DiffMode::Unified,
78 scroll_offset: 0,
79 unchanged_style: Style::default(),
80 added_style: Style::default()
81 .bg(crate::color::Color::Named(crate::color::NamedColor::Green)),
82 removed_style: Style::default()
83 .bg(crate::color::Color::Named(crate::color::NamedColor::Red)),
84 border: BorderStyle::None,
85 unified_lines: Vec::new(),
86 sbs_pairs: Vec::new(),
87 };
88 view.compute_diff();
89 view
90 }
91
92 #[must_use]
94 pub fn with_mode(mut self, mode: DiffMode) -> Self {
95 self.mode = mode;
96 self
97 }
98
99 #[must_use]
101 pub fn with_unchanged_style(mut self, style: Style) -> Self {
102 self.unchanged_style = style;
103 self
104 }
105
106 #[must_use]
108 pub fn with_added_style(mut self, style: Style) -> Self {
109 self.added_style = style;
110 self
111 }
112
113 #[must_use]
115 pub fn with_removed_style(mut self, style: Style) -> Self {
116 self.removed_style = style;
117 self
118 }
119
120 #[must_use]
122 pub fn with_border(mut self, border: BorderStyle) -> Self {
123 self.border = border;
124 self
125 }
126
127 pub fn set_texts(&mut self, old_text: &str, new_text: &str) {
129 self.old_text = old_text.to_string();
130 self.new_text = new_text.to_string();
131 self.scroll_offset = 0;
132 self.compute_diff();
133 }
134
135 pub fn set_mode(&mut self, mode: DiffMode) {
137 self.mode = mode;
138 self.scroll_offset = 0;
139 }
140
141 pub fn mode(&self) -> DiffMode {
143 self.mode
144 }
145
146 pub fn line_count(&self) -> usize {
148 match self.mode {
149 DiffMode::Unified => self.unified_lines.len(),
150 DiffMode::SideBySide => self.sbs_pairs.len(),
151 }
152 }
153
154 pub fn scroll_offset(&self) -> usize {
156 self.scroll_offset
157 }
158
159 fn compute_diff(&mut self) {
161 let diff = TextDiff::from_lines(&self.old_text, &self.new_text);
162
163 self.unified_lines.clear();
165 for change in diff.iter_all_changes() {
166 let text = change.to_string_lossy().trim_end_matches('\n').to_string();
167 self.unified_lines.push(DiffLine {
168 tag: change.tag(),
169 text,
170 });
171 }
172
173 self.sbs_pairs.clear();
175 let mut old_lines: Vec<DiffLine> = Vec::new();
176 let mut new_lines: Vec<DiffLine> = Vec::new();
177
178 for change in diff.iter_all_changes() {
179 let text = change.to_string_lossy().trim_end_matches('\n').to_string();
180 match change.tag() {
181 ChangeTag::Equal => {
182 flush_sbs_pairs(&mut self.sbs_pairs, &mut old_lines, &mut new_lines);
183 self.sbs_pairs.push(SideBySidePair {
184 left: Some(DiffLine {
185 tag: ChangeTag::Equal,
186 text: text.clone(),
187 }),
188 right: Some(DiffLine {
189 tag: ChangeTag::Equal,
190 text,
191 }),
192 });
193 }
194 ChangeTag::Delete => {
195 old_lines.push(DiffLine {
196 tag: ChangeTag::Delete,
197 text,
198 });
199 }
200 ChangeTag::Insert => {
201 new_lines.push(DiffLine {
202 tag: ChangeTag::Insert,
203 text,
204 });
205 }
206 }
207 }
208 flush_sbs_pairs(&mut self.sbs_pairs, &mut old_lines, &mut new_lines);
209 }
210
211 fn style_for_tag(&self, tag: ChangeTag) -> &Style {
213 match tag {
214 ChangeTag::Equal => &self.unchanged_style,
215 ChangeTag::Insert => &self.added_style,
216 ChangeTag::Delete => &self.removed_style,
217 }
218 }
219
220 fn prefix_for_tag(tag: ChangeTag) -> &'static str {
222 match tag {
223 ChangeTag::Equal => " ",
224 ChangeTag::Insert => "+",
225 ChangeTag::Delete => "-",
226 }
227 }
228
229 fn render_line(
231 &self,
232 text: &str,
233 style: &Style,
234 x: u16,
235 y: u16,
236 max_width: usize,
237 buf: &mut ScreenBuffer,
238 ) {
239 let truncated = truncate_to_display_width(text, max_width);
240 let mut col: u16 = 0;
241 for ch in truncated.chars() {
242 let char_w = unicode_width::UnicodeWidthStr::width(ch.encode_utf8(&mut [0; 4]) as &str);
243 if col as usize + char_w > max_width {
244 break;
245 }
246 buf.set(x + col, y, Cell::new(ch.to_string(), style.clone()));
247 col += char_w as u16;
248 }
249 }
250
251 fn render_unified(&self, inner: Rect, buf: &mut ScreenBuffer) {
253 let height = inner.size.height as usize;
254 let width = inner.size.width as usize;
255 let count = self.unified_lines.len();
256 let max_offset = count.saturating_sub(height.max(1));
257 let scroll = self.scroll_offset.min(max_offset);
258 let end = (scroll + height).min(count);
259
260 for (row, line_idx) in (scroll..end).enumerate() {
261 let y = inner.position.y + row as u16;
262 if let Some(line) = self.unified_lines.get(line_idx) {
263 let style = self.style_for_tag(line.tag);
264 let prefix = Self::prefix_for_tag(line.tag);
265
266 for col in 0..inner.size.width {
268 buf.set(inner.position.x + col, y, Cell::new(" ", style.clone()));
269 }
270
271 if width > 0 {
273 buf.set(inner.position.x, y, Cell::new(prefix, style.clone()));
274 }
275
276 if width > 1 {
278 self.render_line(
279 &line.text,
280 style,
281 inner.position.x + 1,
282 y,
283 width.saturating_sub(1),
284 buf,
285 );
286 }
287 }
288 }
289 }
290
291 fn render_side_by_side(&self, inner: Rect, buf: &mut ScreenBuffer) {
293 let height = inner.size.height as usize;
294 let total_width = inner.size.width as usize;
295 let count = self.sbs_pairs.len();
296 let max_offset = count.saturating_sub(height.max(1));
297 let scroll = self.scroll_offset.min(max_offset);
298 let end = (scroll + height).min(count);
299
300 if total_width < 3 {
302 return;
303 }
304 let separator_col = total_width / 2;
305 let left_width = separator_col;
306 let right_width = total_width.saturating_sub(separator_col + 1);
307
308 for row in 0..inner.size.height {
310 buf.set(
311 inner.position.x + separator_col as u16,
312 inner.position.y + row,
313 Cell::new("\u{2502}", self.unchanged_style.clone()), );
315 }
316
317 for (row, pair_idx) in (scroll..end).enumerate() {
318 let y = inner.position.y + row as u16;
319 if let Some(pair) = self.sbs_pairs.get(pair_idx) {
320 if let Some(ref left) = pair.left {
322 let style = self.style_for_tag(left.tag);
323 for col in 0..left_width {
325 buf.set(
326 inner.position.x + col as u16,
327 y,
328 Cell::new(" ", style.clone()),
329 );
330 }
331 self.render_line(&left.text, style, inner.position.x, y, left_width, buf);
332 }
333
334 if let Some(ref right) = pair.right {
336 let style = self.style_for_tag(right.tag);
337 let right_x = inner.position.x + separator_col as u16 + 1;
338 for col in 0..right_width {
340 buf.set(right_x + col as u16, y, Cell::new(" ", style.clone()));
341 }
342 self.render_line(&right.text, style, right_x, y, right_width, buf);
343 }
344 }
345 }
346 }
347}
348
349impl Widget for DiffView {
350 fn render(&self, area: Rect, buf: &mut ScreenBuffer) {
351 if area.size.width == 0 || area.size.height == 0 {
352 return;
353 }
354
355 super::border::render_border(area, self.border, self.unchanged_style.clone(), buf);
356
357 let inner = super::border::inner_area(area, self.border);
358 if inner.size.width == 0 || inner.size.height == 0 {
359 return;
360 }
361
362 match self.mode {
363 DiffMode::Unified => self.render_unified(inner, buf),
364 DiffMode::SideBySide => self.render_side_by_side(inner, buf),
365 }
366 }
367}
368
369impl InteractiveWidget for DiffView {
370 fn handle_event(&mut self, event: &Event) -> EventResult {
371 let Event::Key(KeyEvent { code, .. }) = event else {
372 return EventResult::Ignored;
373 };
374
375 let count = self.line_count();
376
377 match code {
378 KeyCode::Up => {
379 if self.scroll_offset > 0 {
380 self.scroll_offset -= 1;
381 }
382 EventResult::Consumed
383 }
384 KeyCode::Down => {
385 if count > 0 && self.scroll_offset < count.saturating_sub(1) {
386 self.scroll_offset += 1;
387 }
388 EventResult::Consumed
389 }
390 KeyCode::PageUp => {
391 self.scroll_offset = self.scroll_offset.saturating_sub(20);
392 EventResult::Consumed
393 }
394 KeyCode::PageDown => {
395 if count > 0 {
396 self.scroll_offset = (self.scroll_offset + 20).min(count.saturating_sub(1));
397 }
398 EventResult::Consumed
399 }
400 KeyCode::Home => {
401 self.scroll_offset = 0;
402 EventResult::Consumed
403 }
404 KeyCode::End => {
405 if count > 0 {
406 self.scroll_offset = count.saturating_sub(1);
407 }
408 EventResult::Consumed
409 }
410 KeyCode::Char('m') => {
411 self.mode = match self.mode {
412 DiffMode::Unified => DiffMode::SideBySide,
413 DiffMode::SideBySide => DiffMode::Unified,
414 };
415 self.scroll_offset = 0;
416 EventResult::Consumed
417 }
418 _ => EventResult::Ignored,
419 }
420 }
421}
422
423fn flush_sbs_pairs(
425 pairs: &mut Vec<SideBySidePair>,
426 old_lines: &mut Vec<DiffLine>,
427 new_lines: &mut Vec<DiffLine>,
428) {
429 let max_len = old_lines.len().max(new_lines.len());
430 for i in 0..max_len {
431 pairs.push(SideBySidePair {
432 left: old_lines.get(i).cloned(),
433 right: new_lines.get(i).cloned(),
434 });
435 }
436 old_lines.clear();
437 new_lines.clear();
438}
439
440#[cfg(test)]
441#[allow(clippy::unwrap_used)]
442mod tests {
443 use super::*;
444 use crate::geometry::Size;
445
446 #[test]
447 fn create_diff_view() {
448 let dv = DiffView::new("hello\nworld\n", "hello\nrust\n");
449 assert_eq!(dv.mode(), DiffMode::Unified);
450 assert!(dv.line_count() > 0);
451 }
452
453 #[test]
454 fn unified_prefixes() {
455 let dv = DiffView::new("aaa\nbbb\n", "aaa\nccc\n");
456
457 assert_eq!(dv.unified_lines.len(), 3);
459 assert_eq!(dv.unified_lines[0].tag, ChangeTag::Equal);
460 assert_eq!(dv.unified_lines[0].text, "aaa");
461 assert_eq!(dv.unified_lines[1].tag, ChangeTag::Delete);
462 assert_eq!(dv.unified_lines[1].text, "bbb");
463 assert_eq!(dv.unified_lines[2].tag, ChangeTag::Insert);
464 assert_eq!(dv.unified_lines[2].text, "ccc");
465 }
466
467 #[test]
468 fn side_by_side_pairs() {
469 let dv = DiffView::new("aaa\nbbb\n", "aaa\nccc\n").with_mode(DiffMode::SideBySide);
470
471 assert_eq!(dv.mode(), DiffMode::SideBySide);
472 assert_eq!(dv.sbs_pairs.len(), 2);
475
476 assert!(dv.sbs_pairs[0].left.is_some());
477 assert!(dv.sbs_pairs[0].right.is_some());
478 assert_eq!(
479 dv.sbs_pairs[0].left.as_ref().map(|l| l.tag),
480 Some(ChangeTag::Equal)
481 );
482
483 assert!(dv.sbs_pairs[1].left.is_some());
484 assert!(dv.sbs_pairs[1].right.is_some());
485 assert_eq!(
486 dv.sbs_pairs[1].left.as_ref().map(|l| l.tag),
487 Some(ChangeTag::Delete)
488 );
489 assert_eq!(
490 dv.sbs_pairs[1].right.as_ref().map(|l| l.tag),
491 Some(ChangeTag::Insert)
492 );
493 }
494
495 #[test]
496 fn scroll_up_down() {
497 let mut dv = DiffView::new("a\nb\nc\nd\ne\nf\n", "a\nb\nc\nd\ne\nf\n");
498
499 let down = Event::Key(KeyEvent {
500 code: KeyCode::Down,
501 modifiers: crate::event::Modifiers::NONE,
502 });
503 let up = Event::Key(KeyEvent {
504 code: KeyCode::Up,
505 modifiers: crate::event::Modifiers::NONE,
506 });
507
508 assert_eq!(dv.scroll_offset(), 0);
509 dv.handle_event(&down);
510 assert_eq!(dv.scroll_offset(), 1);
511 dv.handle_event(&up);
512 assert_eq!(dv.scroll_offset(), 0);
513 dv.handle_event(&up);
515 assert_eq!(dv.scroll_offset(), 0);
516 }
517
518 #[test]
519 fn page_up_down() {
520 let mut dv = DiffView::new(
521 "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12\nline13\nline14\nline15\nline16\nline17\nline18\nline19\nline20\nline21\nline22\nline23\nline24\nline25\n",
522 "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12\nline13\nline14\nline15\nline16\nline17\nline18\nline19\nline20\nline21\nline22\nline23\nline24\nline25\n",
523 );
524
525 let pgdn = Event::Key(KeyEvent {
526 code: KeyCode::PageDown,
527 modifiers: crate::event::Modifiers::NONE,
528 });
529 let pgup = Event::Key(KeyEvent {
530 code: KeyCode::PageUp,
531 modifiers: crate::event::Modifiers::NONE,
532 });
533
534 dv.handle_event(&pgdn);
535 assert_eq!(dv.scroll_offset(), 20);
536 dv.handle_event(&pgup);
537 assert_eq!(dv.scroll_offset(), 0);
538 }
539
540 #[test]
541 fn home_end() {
542 let mut dv = DiffView::new("a\nb\nc\nd\ne\n", "a\nb\nc\nd\ne\n");
543
544 let end_key = Event::Key(KeyEvent {
545 code: KeyCode::End,
546 modifiers: crate::event::Modifiers::NONE,
547 });
548 let home_key = Event::Key(KeyEvent {
549 code: KeyCode::Home,
550 modifiers: crate::event::Modifiers::NONE,
551 });
552
553 dv.handle_event(&end_key);
554 assert_eq!(dv.scroll_offset(), dv.line_count().saturating_sub(1));
555 dv.handle_event(&home_key);
556 assert_eq!(dv.scroll_offset(), 0);
557 }
558
559 #[test]
560 fn toggle_mode_with_m() {
561 let mut dv = DiffView::new("a\n", "b\n");
562 assert_eq!(dv.mode(), DiffMode::Unified);
563
564 let m = Event::Key(KeyEvent {
565 code: KeyCode::Char('m'),
566 modifiers: crate::event::Modifiers::NONE,
567 });
568
569 dv.handle_event(&m);
570 assert_eq!(dv.mode(), DiffMode::SideBySide);
571 dv.handle_event(&m);
572 assert_eq!(dv.mode(), DiffMode::Unified);
573 }
574
575 #[test]
576 fn empty_diff_identical_texts() {
577 let dv = DiffView::new("same\n", "same\n");
578 assert_eq!(dv.unified_lines.len(), 1);
579 assert_eq!(dv.unified_lines[0].tag, ChangeTag::Equal);
580 }
581
582 #[test]
583 fn all_added_old_empty() {
584 let dv = DiffView::new("", "new1\nnew2\n");
585 for line in &dv.unified_lines {
586 assert_eq!(line.tag, ChangeTag::Insert);
587 }
588 }
589
590 #[test]
591 fn all_removed_new_empty() {
592 let dv = DiffView::new("old1\nold2\n", "");
593 for line in &dv.unified_lines {
594 assert_eq!(line.tag, ChangeTag::Delete);
595 }
596 }
597
598 #[test]
599 fn mixed_changes() {
600 let dv = DiffView::new("a\nb\nc\n", "a\nB\nc\nd\n");
601 let tags: Vec<ChangeTag> = dv.unified_lines.iter().map(|l| l.tag).collect();
603 assert!(tags.contains(&ChangeTag::Equal));
604 assert!(tags.contains(&ChangeTag::Delete));
605 assert!(tags.contains(&ChangeTag::Insert));
606 }
607
608 #[test]
609 fn render_unified_mode() {
610 let dv = DiffView::new("old\n", "new\n");
611 let mut buf = ScreenBuffer::new(Size::new(30, 5));
612 dv.render(Rect::new(0, 0, 30, 5), &mut buf);
613
614 assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some("-"));
616 assert_eq!(buf.get(0, 1).map(|c| c.grapheme.as_str()), Some("+"));
618 }
619
620 #[test]
621 fn render_side_by_side_mode() {
622 let dv = DiffView::new("old\n", "new\n").with_mode(DiffMode::SideBySide);
623 let mut buf = ScreenBuffer::new(Size::new(20, 5));
624 dv.render(Rect::new(0, 0, 20, 5), &mut buf);
625
626 assert_eq!(
628 buf.get(10, 0).map(|c| c.grapheme.as_str()),
629 Some("\u{2502}")
630 );
631 }
632
633 #[test]
634 fn set_texts_recomputes() {
635 let mut dv = DiffView::new("a\n", "b\n");
636 let initial_count = dv.line_count();
637
638 dv.set_texts("x\ny\nz\n", "x\nw\nz\n");
639 assert!(dv.line_count() > 0);
641 assert_eq!(dv.scroll_offset(), 0);
643 let _ = initial_count;
645 }
646
647 #[test]
648 fn border_rendering() {
649 let dv = DiffView::new("a\n", "b\n").with_border(BorderStyle::Single);
650 let mut buf = ScreenBuffer::new(Size::new(30, 10));
651 dv.render(Rect::new(0, 0, 30, 10), &mut buf);
652
653 assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some("\u{250c}"));
654 }
655
656 #[test]
657 fn utf8_safe_diff() {
658 let dv = DiffView::new("你好\n", "世界\n");
659 assert_eq!(dv.line_count(), 2); let mut buf = ScreenBuffer::new(Size::new(20, 5));
662 dv.render(Rect::new(0, 0, 20, 5), &mut buf);
663
664 assert!(buf.get(0, 0).is_some());
666 }
667}