Skip to main content

journey/widgets/
diff_view.rs

1//! A read-only, scrollable, syntax-colored unified-diff viewer.
2//!
3//! `DiffView` renders a [`Diff`] line-by-line in the monospace font, tinting
4//! additions green, deletions red, hunk headers blue and file headers gray —
5//! the standard gitk / `git diff --color` palette adapted to saudade's Win
6//! 3.1 chrome. It owns a vertical scrollbar pinned to the right edge and, like
7//! saudade's `List`, only measures and paints the rows currently on screen.
8
9use saudade::{
10    Color, Event, EventCtx, Key, MouseButton, NamedKey, Painter, Rect, SCROLLBAR_THICKNESS,
11    ScrollBar, Theme, Widget,
12};
13
14use crate::backend::{Diff, DiffLineKind};
15
16const TEXT_PAD_X: i32 = 4;
17const TEXT_PAD_Y: i32 = 2;
18
19// Diff palette — readable on the sunken white field.
20const ADD_BG: Color = Color::rgb(0xDC, 0xFF, 0xDC);
21const ADD_FG: Color = Color::rgb(0x00, 0x64, 0x00);
22const DEL_BG: Color = Color::rgb(0xFF, 0xDC, 0xDC);
23const DEL_FG: Color = Color::rgb(0x90, 0x00, 0x00);
24const HUNK_BG: Color = Color::rgb(0xE2, 0xE8, 0xFF);
25const HUNK_FG: Color = Color::rgb(0x00, 0x00, 0x80);
26const COMMIT_BG: Color = Color::rgb(0xFF, 0xF6, 0xCC);
27const COMMIT_FG: Color = Color::rgb(0x40, 0x30, 0x00);
28const FILE_BG: Color = Color::rgb(0xE6, 0xE6, 0xE6);
29const FILE_FG: Color = Color::rgb(0x00, 0x00, 0x00);
30const META_FG: Color = Color::rgb(0x80, 0x80, 0x80);
31const CONTEXT_FG: Color = Color::rgb(0x20, 0x20, 0x20);
32
33/// A read-only diff pane.
34pub struct DiffView {
35    rect: Rect,
36    diff: Diff,
37    v_scrollbar: ScrollBar,
38    focused: bool,
39    font_size: f32,
40}
41
42impl DiffView {
43    pub fn new(rect: Rect) -> Self {
44        let mut me = Self {
45            rect,
46            diff: Diff::default(),
47            v_scrollbar: ScrollBar::vertical(Rect::new(0, 0, 0, 0)),
48            focused: false,
49            font_size: 12.0,
50        };
51        me.relayout_scrollbar();
52        me
53    }
54
55    pub fn with_font_size(mut self, size: f32) -> Self {
56        self.font_size = size;
57        self
58    }
59
60    /// Replace the displayed diff and reset the scroll position to the top.
61    pub fn set_diff(&mut self, diff: Diff) {
62        self.diff = diff;
63        self.v_scrollbar.set_value(0);
64        self.sync_scrollbar();
65    }
66
67    pub fn is_empty(&self) -> bool {
68        self.diff.is_empty()
69    }
70
71    fn line_height(&self) -> i32 {
72        (self.font_size as i32 + 4).max(8)
73    }
74
75    fn text_area(&self) -> Rect {
76        let sb_w = if self.v_scrollbar.rect().w > 0 {
77            SCROLLBAR_THICKNESS
78        } else {
79            0
80        };
81        Rect::new(
82            self.rect.x,
83            self.rect.y,
84            (self.rect.w - sb_w).max(0),
85            self.rect.h,
86        )
87    }
88
89    fn visible_rows(&self) -> i32 {
90        ((self.text_area().h - TEXT_PAD_Y * 2) / self.line_height()).max(1)
91    }
92
93    fn scroll_top(&self) -> usize {
94        self.v_scrollbar.value().max(0) as usize
95    }
96
97    fn sync_scrollbar(&mut self) {
98        let visible = self.visible_rows();
99        let max_scroll = (self.diff.lines.len() as i32 - visible).max(0);
100        self.v_scrollbar.set_range(visible, max_scroll);
101        self.v_scrollbar.set_line_step(1);
102    }
103
104    fn relayout_scrollbar(&mut self) {
105        let sb_rect = Rect::new(
106            self.rect.right() - SCROLLBAR_THICKNESS,
107            self.rect.y,
108            SCROLLBAR_THICKNESS,
109            self.rect.h,
110        );
111        self.v_scrollbar.set_rect(sb_rect);
112        self.sync_scrollbar();
113    }
114
115    fn scroll_by(&mut self, delta: i32) {
116        let v = self.v_scrollbar.value();
117        self.v_scrollbar.set_value(v + delta);
118    }
119}
120
121impl Widget for DiffView {
122    fn bounds(&self) -> Rect {
123        self.rect
124    }
125
126    fn paint(&mut self, painter: &mut Painter, theme: &Theme) {
127        self.sync_scrollbar();
128        let text = self.text_area();
129        painter.fill_rect(text, Color::WHITE);
130        painter.sunken_bevel(text, theme.highlight, theme.shadow);
131        painter.stroke_rect(text, theme.border);
132
133        let line_h = self.line_height();
134        let text_x = text.x + TEXT_PAD_X;
135        let text_y0 = text.y + TEXT_PAD_Y;
136        let row_w = (text.w - TEXT_PAD_X).max(0);
137        let visible = self.visible_rows() as usize;
138        let scroll_top = self.scroll_top();
139
140        // Clip so long lines don't bleed across the scrollbar or the border.
141        let saved = painter.push_clip(text.inset(1));
142        for row_offset in 0..visible {
143            let row = scroll_top + row_offset;
144            let Some(line) = self.diff.lines.get(row) else {
145                break;
146            };
147            let y = text_y0 + row_offset as i32 * line_h;
148            let (fg, bg) = colors_for(line.kind);
149            if let Some(bg) = bg {
150                painter.fill_rect(Rect::new(text.x + 1, y, row_w, line_h), bg);
151            }
152            let label_y = y + (line_h - self.font_size as i32) / 2 - 1;
153            painter.mono_text(text_x, label_y, &line.text, self.font_size, fg);
154        }
155        painter.restore_clip(saved);
156
157        self.v_scrollbar.paint(painter, theme);
158    }
159
160    fn event(&mut self, event: &Event, ctx: &mut EventCtx) {
161        // Route to the scrollbar while it's dragging or being clicked.
162        if self.v_scrollbar.captures_pointer() {
163            self.v_scrollbar.event(event, ctx);
164            return;
165        }
166        if let Some(pos) = event.position()
167            && self.v_scrollbar.rect().contains(pos)
168        {
169            self.v_scrollbar.event(event, ctx);
170            return;
171        }
172
173        match event {
174            Event::PointerDown {
175                button: MouseButton::Left,
176                ..
177            } => {
178                ctx.request_focus();
179                ctx.request_paint();
180            }
181            Event::KeyDown { key, modifiers } if self.focused && !modifiers.has_command() => {
182                let page = (self.visible_rows() - 1).max(1);
183                let consumed = match key {
184                    Key::Named(NamedKey::Up) => {
185                        self.scroll_by(-1);
186                        true
187                    }
188                    Key::Named(NamedKey::Down) => {
189                        self.scroll_by(1);
190                        true
191                    }
192                    Key::Named(NamedKey::PageUp) => {
193                        self.scroll_by(-page);
194                        true
195                    }
196                    Key::Named(NamedKey::PageDown) => {
197                        self.scroll_by(page);
198                        true
199                    }
200                    Key::Named(NamedKey::Home) => {
201                        self.v_scrollbar.set_value(0);
202                        true
203                    }
204                    Key::Named(NamedKey::End) => {
205                        self.v_scrollbar.set_value(self.diff.lines.len() as i32);
206                        true
207                    }
208                    _ => false,
209                };
210                if consumed {
211                    ctx.request_paint();
212                }
213            }
214            _ => {}
215        }
216    }
217
218    fn captures_pointer(&self) -> bool {
219        self.v_scrollbar.captures_pointer()
220    }
221
222    fn focusable(&self) -> bool {
223        true
224    }
225
226    fn set_focused(&mut self, focused: bool) {
227        self.focused = focused;
228    }
229
230    fn layout(&mut self, bounds: Rect) {
231        self.rect = bounds;
232        self.relayout_scrollbar();
233    }
234}
235
236/// Foreground color and optional row background tint for a diff line kind.
237fn colors_for(kind: DiffLineKind) -> (Color, Option<Color>) {
238    match kind {
239        DiffLineKind::CommitHeader => (COMMIT_FG, Some(COMMIT_BG)),
240        DiffLineKind::Addition => (ADD_FG, Some(ADD_BG)),
241        DiffLineKind::Deletion => (DEL_FG, Some(DEL_BG)),
242        DiffLineKind::HunkHeader => (HUNK_FG, Some(HUNK_BG)),
243        DiffLineKind::FileHeader => (FILE_FG, Some(FILE_BG)),
244        DiffLineKind::Meta => (META_FG, None),
245        DiffLineKind::Context => (CONTEXT_FG, None),
246    }
247}