Skip to main content

saorsa_core/widget/
diff_view.rs

1//! Diff viewer widget with unified and side-by-side display modes.
2//!
3//! Uses the [`similar`] crate to compute line-by-line diffs and displays
4//! them with color-coded added/removed/unchanged styling.
5
6use 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/// Display mode for the diff viewer.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum DiffMode {
20    /// Unified diff: single column with +/- prefixes.
21    Unified,
22    /// Side-by-side diff: old on left, new on right.
23    SideBySide,
24}
25
26/// A line in the computed diff.
27#[derive(Clone, Debug)]
28struct DiffLine {
29    /// The diff tag for this line.
30    tag: ChangeTag,
31    /// The text content (without newline).
32    text: String,
33}
34
35/// A pair of lines for side-by-side display.
36#[derive(Clone, Debug)]
37struct SideBySidePair {
38    /// Left (old) line, if any.
39    left: Option<DiffLine>,
40    /// Right (new) line, if any.
41    right: Option<DiffLine>,
42}
43
44/// A diff viewer widget that displays text differences.
45///
46/// Supports unified and side-by-side display modes with color-coded
47/// added, removed, and unchanged lines.
48pub struct DiffView {
49    /// Original text.
50    old_text: String,
51    /// Modified text.
52    new_text: String,
53    /// Display mode.
54    mode: DiffMode,
55    /// Scroll offset (first visible line).
56    scroll_offset: usize,
57    /// Style for unchanged lines.
58    unchanged_style: Style,
59    /// Style for added lines.
60    added_style: Style,
61    /// Style for removed lines.
62    removed_style: Style,
63    /// Border style.
64    border: BorderStyle,
65    /// Cached unified diff lines.
66    unified_lines: Vec<DiffLine>,
67    /// Cached side-by-side pairs.
68    sbs_pairs: Vec<SideBySidePair>,
69}
70
71impl DiffView {
72    /// Create a new diff view comparing old and new text.
73    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    /// Set the display mode.
93    #[must_use]
94    pub fn with_mode(mut self, mode: DiffMode) -> Self {
95        self.mode = mode;
96        self
97    }
98
99    /// Set the style for unchanged lines.
100    #[must_use]
101    pub fn with_unchanged_style(mut self, style: Style) -> Self {
102        self.unchanged_style = style;
103        self
104    }
105
106    /// Set the style for added lines.
107    #[must_use]
108    pub fn with_added_style(mut self, style: Style) -> Self {
109        self.added_style = style;
110        self
111    }
112
113    /// Set the style for removed lines.
114    #[must_use]
115    pub fn with_removed_style(mut self, style: Style) -> Self {
116        self.removed_style = style;
117        self
118    }
119
120    /// Set the border style.
121    #[must_use]
122    pub fn with_border(mut self, border: BorderStyle) -> Self {
123        self.border = border;
124        self
125    }
126
127    /// Set new texts and recompute the diff.
128    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    /// Switch display mode.
136    pub fn set_mode(&mut self, mode: DiffMode) {
137        self.mode = mode;
138        self.scroll_offset = 0;
139    }
140
141    /// Get the current display mode.
142    pub fn mode(&self) -> DiffMode {
143        self.mode
144    }
145
146    /// Get the total number of display lines for the current mode.
147    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    /// Get the scroll offset.
155    pub fn scroll_offset(&self) -> usize {
156        self.scroll_offset
157    }
158
159    /// Compute the diff between old and new text.
160    fn compute_diff(&mut self) {
161        let diff = TextDiff::from_lines(&self.old_text, &self.new_text);
162
163        // Build unified lines
164        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        // Build side-by-side pairs
174        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    /// Get the style for a given change tag.
212    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    /// Get the prefix character for a given change tag.
221    fn prefix_for_tag(tag: ChangeTag) -> &'static str {
222        match tag {
223            ChangeTag::Equal => " ",
224            ChangeTag::Insert => "+",
225            ChangeTag::Delete => "-",
226        }
227    }
228
229    /// Render a single line of text into the buffer at the given position.
230    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    /// Render in unified mode.
252    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                // Fill the full row with the style
267                for col in 0..inner.size.width {
268                    buf.set(inner.position.x + col, y, Cell::new(" ", style.clone()));
269                }
270
271                // Render prefix
272                if width > 0 {
273                    buf.set(inner.position.x, y, Cell::new(prefix, style.clone()));
274                }
275
276                // Render text content (after prefix)
277                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    /// Render in side-by-side mode.
292    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        // Split width: left half | separator | right half
301        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        // Draw separator
309        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()), // │
314            );
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                // Left side
321                if let Some(ref left) = pair.left {
322                    let style = self.style_for_tag(left.tag);
323                    // Fill left side with style
324                    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                // Right side
335                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                    // Fill right side with style
339                    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
423/// Flush accumulated old/new lines into side-by-side pairs.
424fn 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        // Should have: " aaa", "-bbb", "+ccc"
458        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        // Pair 1: aaa | aaa (equal)
473        // Pair 2: bbb | ccc (delete | insert)
474        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        // Up at 0 stays 0
514        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        // a = equal, b = delete, B = insert, c = equal, d = insert
602        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        // First line should have "-" prefix (delete)
615        assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some("-"));
616        // Second line should have "+" prefix (insert)
617        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        // Separator at column 10 (width/2)
627        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        // Should have recomputed
640        assert!(dv.line_count() > 0);
641        // Scroll should be reset
642        assert_eq!(dv.scroll_offset(), 0);
643        // Count may differ
644        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); // one delete, one insert
660
661        let mut buf = ScreenBuffer::new(Size::new(20, 5));
662        dv.render(Rect::new(0, 0, 20, 5), &mut buf);
663
664        // Should render without panic
665        assert!(buf.get(0, 0).is_some());
666    }
667}