Skip to main content

journey/widgets/
diff_view.rs

1//! A 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//!
9//! In the commit screen it also gains a *line-range selection*: the user
10//! click-drags (or clicks one line and Shift-clicks another) to highlight an
11//! adjacent block of lines, which gets a translucent overlay and an animated
12//! "marching ants" border, and a small **Stage** / **Unstage** button floats in
13//! the selection's bottom-right corner so part of a file's diff can be staged
14//! without staging the whole file. This is enabled only by [`set_mode`] with a
15//! non-[`DiffMode::Plain`] mode, which the browse view never does.
16//!
17//! [`set_mode`]: DiffView::set_mode
18
19use saudade::{
20    Color, Event, EventCtx, FontFamily, FontStyle, Key, MouseButton, NamedKey, Painter, Point,
21    Rect, SCROLLBAR_THICKNESS, ScrollBar, Theme, Widget,
22};
23
24use crate::backend::{Diff, DiffLineKind, is_change_line};
25
26const TEXT_PAD_X: i32 = 4;
27const TEXT_PAD_Y: i32 = 2;
28
29// Diff palette — readable on the sunken white field.
30const ADD_BG: Color = Color::rgb(0xDC, 0xFF, 0xDC);
31const ADD_FG: Color = Color::rgb(0x00, 0x64, 0x00);
32const DEL_BG: Color = Color::rgb(0xFF, 0xDC, 0xDC);
33const DEL_FG: Color = Color::rgb(0x90, 0x00, 0x00);
34const HUNK_BG: Color = Color::rgb(0xE2, 0xE8, 0xFF);
35const HUNK_FG: Color = Color::rgb(0x00, 0x00, 0x80);
36const COMMIT_BG: Color = Color::rgb(0xFF, 0xF6, 0xCC);
37const COMMIT_FG: Color = Color::rgb(0x40, 0x30, 0x00);
38const FILE_BG: Color = Color::rgb(0xE6, 0xE6, 0xE6);
39const FILE_FG: Color = Color::rgb(0x00, 0x00, 0x00);
40const META_FG: Color = Color::rgb(0x80, 0x80, 0x80);
41const CONTEXT_FG: Color = Color::rgb(0x20, 0x20, 0x20);
42
43// Selection chrome. The overlay is a 50%-coverage checkerboard of `SEL_OVERLAY`
44// stippled over the already-painted diff — the toolkit has no alpha-blended
45// fill, and a stipple is the authentic Win 3.1 way to read as ~50% opacity
46// while letting the text show through. The border is animated marching ants
47// alternating between `ANT_LIGHT` and `ANT_DARK`.
48const SEL_OVERLAY: Color = Color::rgb(0x33, 0x66, 0xCC);
49const ANT_LIGHT: Color = Color::WHITE;
50const ANT_DARK: Color = Color::rgb(0x00, 0x33, 0x99);
51/// Run length (logical px) of one marching-ant dash.
52const ANT_DASH: i32 = 3;
53/// Advance the ant phase once every N ticks (~60 Hz), throttling the animation
54/// — and the repaints it drives — to a calm march rather than a 60 fps blur.
55const ANT_TICK_DIV: u32 = 3;
56
57/// Whether the diff view offers line-range staging, and which way. The browse
58/// view stays [`DiffMode::Plain`]; the commit view sets [`DiffMode::Stage`] for
59/// an unstaged file and [`DiffMode::Unstage`] for a staged one.
60#[derive(Clone, Copy, PartialEq, Eq)]
61pub enum DiffMode {
62    /// Read-only: no selection, no staging affordance.
63    Plain,
64    /// Selecting lines offers to stage them (unstaged-file diff).
65    Stage,
66    /// Selecting lines offers to unstage them (staged-file diff).
67    Unstage,
68}
69
70/// A diff pane, read-only in browse mode and line-stageable in commit mode.
71pub struct DiffView {
72    rect: Rect,
73    diff: Diff,
74    v_scrollbar: ScrollBar,
75    focused: bool,
76    font_size: f32,
77    mode: DiffMode,
78    /// The fixed end of a range selection (the row where it was anchored).
79    anchor: Option<usize>,
80    /// The moving end of a range selection (the last row touched).
81    lead: Option<usize>,
82    /// A press-drag selection is in progress.
83    dragging: bool,
84    /// Marching-ants animation phase, advanced on `Tick`.
85    ant_phase: u32,
86    /// Tick counter used to throttle phase advances to [`ANT_TICK_DIV`].
87    tick_accum: u32,
88    /// Set when the Stage/Unstage button is clicked: the inclusive selected row
89    /// range, drained by the UI via [`take_action`](Self::take_action).
90    pending_action: Option<(usize, usize)>,
91    /// The Stage/Unstage button's bounds from the last paint, for hit-testing.
92    button_rect: Option<Rect>,
93    /// A press on the Stage/Unstage button is in progress (mouse went down on
94    /// it and hasn't been released yet). The action fires only on release *over*
95    /// the button — like a real push button.
96    button_pressed: bool,
97    /// While a press is in progress, whether the cursor is currently over the
98    /// button (so it draws sunken, and pops back up if the user drags off).
99    button_hot: bool,
100}
101
102impl DiffView {
103    pub fn new(rect: Rect) -> Self {
104        let mut me = Self {
105            rect,
106            diff: Diff::default(),
107            v_scrollbar: ScrollBar::vertical(Rect::new(0, 0, 0, 0)),
108            focused: false,
109            font_size: 12.0,
110            mode: DiffMode::Plain,
111            anchor: None,
112            lead: None,
113            dragging: false,
114            ant_phase: 0,
115            tick_accum: 0,
116            pending_action: None,
117            button_rect: None,
118            button_pressed: false,
119            button_hot: false,
120        };
121        me.relayout_scrollbar();
122        me
123    }
124
125    pub fn with_font_size(mut self, size: f32) -> Self {
126        self.font_size = size;
127        self
128    }
129
130    /// Replace the displayed diff and reset the scroll position and selection.
131    pub fn set_diff(&mut self, diff: Diff) {
132        self.diff = diff;
133        self.v_scrollbar.set_value(0);
134        self.clear_selection();
135        self.pending_action = None;
136        self.sync_scrollbar();
137    }
138
139    /// Set whether (and how) line-range staging is offered. Switching to
140    /// [`DiffMode::Plain`] clears any selection in progress.
141    pub fn set_mode(&mut self, mode: DiffMode) {
142        if mode == self.mode {
143            return;
144        }
145        self.mode = mode;
146        if mode == DiffMode::Plain {
147            self.clear_selection();
148        }
149    }
150
151    /// Take the pending Stage/Unstage request (the selected inclusive row
152    /// range), if the button was clicked since the last poll.
153    pub fn take_action(&mut self) -> Option<(usize, usize)> {
154        self.pending_action.take()
155    }
156
157    pub fn is_empty(&self) -> bool {
158        self.diff.is_empty()
159    }
160
161    fn clear_selection(&mut self) {
162        self.anchor = None;
163        self.lead = None;
164        self.dragging = false;
165        self.button_pressed = false;
166        self.button_hot = false;
167    }
168
169    /// The raw selected row span `(lo, hi)` from the gesture's two endpoints.
170    fn selection_span(&self) -> Option<(usize, usize)> {
171        match (self.anchor, self.lead) {
172            (Some(a), Some(l)) => Some((a.min(l), a.max(l))),
173            _ => None,
174        }
175    }
176
177    /// The selection clamped to actual *body* rows: the first and last
178    /// selectable (non-header) row within the span. File and hunk headers are
179    /// never part of a selection, so a span that begins or ends on one snaps
180    /// inward to the content. `None` when the span holds no selectable row.
181    fn body_bounds(&self) -> Option<(usize, usize)> {
182        let (lo, hi) = self.selection_span()?;
183        let mut first = None;
184        let mut last = None;
185        for r in lo..=hi {
186            if self
187                .diff
188                .lines
189                .get(r)
190                .is_some_and(|l| is_selectable(l.kind))
191            {
192                first.get_or_insert(r);
193                last = Some(r);
194            }
195        }
196        Some((first?, last?))
197    }
198
199    /// Does the selection cover at least one `+`/`-` row — the only kind a
200    /// partial stage/unstage can act on?
201    fn selection_has_change(&self) -> bool {
202        self.body_bounds().is_some_and(|(lo, hi)| {
203            (lo..=hi).any(|r| {
204                self.diff
205                    .lines
206                    .get(r)
207                    .is_some_and(|l| is_change_line(l.kind))
208            })
209        })
210    }
211
212    /// The body-row range a click on `row` selects: the row itself for a content
213    /// line, the whole hunk for a hunk header. `None` for a file/commit header
214    /// (clicking those clears the selection), an empty hunk, or an out-of-range
215    /// row.
216    fn click_target_range(&self, row: usize) -> Option<(usize, usize)> {
217        match self.diff.lines.get(row)?.kind {
218            DiffLineKind::HunkHeader => self.hunk_body_bounds(row),
219            DiffLineKind::FileHeader | DiffLineKind::CommitHeader => None,
220            _ => Some((row, row)),
221        }
222    }
223
224    /// First/last body row of the hunk introduced by the header at `header_row`.
225    fn hunk_body_bounds(&self, header_row: usize) -> Option<(usize, usize)> {
226        let lines = &self.diff.lines;
227        let start = header_row + 1;
228        if lines.get(start).is_none_or(|l| !is_selectable(l.kind)) {
229            return None;
230        }
231        let mut end = start;
232        while lines.get(end + 1).is_some_and(|l| is_selectable(l.kind)) {
233            end += 1;
234        }
235        Some((start, end))
236    }
237
238    fn line_height(&self) -> i32 {
239        (self.font_size as i32 + 4).max(8)
240    }
241
242    fn text_area(&self) -> Rect {
243        // When the scrollbar is present the field overlaps it by 1px so the
244        // field's right border lands on the scrollbar's own left-border column,
245        // collapsing the divider to a single 1px line instead of stacking the
246        // two 1px borders into a 2px band. The scrollbar is painted last, on
247        // top, so that shared column reads as the scrollbar's edge — exactly
248        // how saudade's `List` does it.
249        let (sb_w, overlap) = if self.v_scrollbar.rect().w > 0 {
250            (SCROLLBAR_THICKNESS, 1)
251        } else {
252            (0, 0)
253        };
254        Rect::new(
255            self.rect.x,
256            self.rect.y,
257            (self.rect.w - sb_w + overlap).max(0),
258            self.rect.h,
259        )
260    }
261
262    fn visible_rows(&self) -> i32 {
263        ((self.text_area().h - TEXT_PAD_Y * 2) / self.line_height()).max(1)
264    }
265
266    fn scroll_top(&self) -> usize {
267        self.v_scrollbar.value().max(0) as usize
268    }
269
270    /// The diff row under `pos`, or `None` when the click is past the last line
271    /// (so a click in the empty area below the diff clears the selection).
272    fn row_at(&self, pos: Point) -> Option<usize> {
273        let text = self.text_area();
274        if !text.inset(1).contains(pos) {
275            return None;
276        }
277        let text_y0 = text.y + TEXT_PAD_Y;
278        let offset = ((pos.y - text_y0).max(0)) / self.line_height();
279        let row = self.scroll_top() + offset as usize;
280        (row < self.diff.lines.len()).then_some(row)
281    }
282
283    /// Like [`row_at`](Self::row_at) but clamped into the content range, used
284    /// while dragging so the selection can extend past the visible edge.
285    fn row_at_clamped(&self, pos: Point) -> Option<usize> {
286        if self.diff.lines.is_empty() {
287            return None;
288        }
289        let text = self.text_area();
290        let rel = pos.y - (text.y + TEXT_PAD_Y);
291        let offset = if rel < 0 { 0 } else { rel / self.line_height() };
292        let row = (self.scroll_top() as i32 + offset).clamp(0, self.diff.lines.len() as i32 - 1);
293        Some(row as usize)
294    }
295
296    fn sync_scrollbar(&mut self) {
297        let visible = self.visible_rows();
298        let max_scroll = (self.diff.lines.len() as i32 - visible).max(0);
299        self.v_scrollbar.set_range(visible, max_scroll);
300        self.v_scrollbar.set_line_step(1);
301    }
302
303    fn relayout_scrollbar(&mut self) {
304        let sb_rect = Rect::new(
305            self.rect.right() - SCROLLBAR_THICKNESS,
306            self.rect.y,
307            SCROLLBAR_THICKNESS,
308            self.rect.h,
309        );
310        self.v_scrollbar.set_rect(sb_rect);
311        self.sync_scrollbar();
312    }
313
314    fn scroll_by(&mut self, delta: i32) {
315        let v = self.v_scrollbar.value();
316        self.v_scrollbar.set_value(v + delta);
317    }
318
319    /// Draw the selection overlay, marching-ants border and Stage/Unstage
320    /// button over the already-painted diff, and cache the button's rect for
321    /// hit-testing. `text` is the text field rect, `row_w` the row fill width.
322    fn paint_selection(&mut self, painter: &mut Painter, theme: &Theme, text: Rect, row_w: i32) {
323        self.button_rect = None;
324        if self.mode == DiffMode::Plain {
325            return;
326        }
327        let Some((lo, hi)) = self.body_bounds() else {
328            return;
329        };
330
331        let line_h = self.line_height();
332        let visible = self.visible_rows() as usize;
333        let top = self.scroll_top();
334        let vis_lo = lo.max(top);
335        let vis_hi = hi.min(top + visible.saturating_sub(1));
336        if vis_lo > vis_hi {
337            return; // selection scrolled out of view
338        }
339
340        let text_y0 = text.y + TEXT_PAD_Y;
341        let row_band = |r: usize| {
342            Rect::new(
343                text.x + 1,
344                text_y0 + (r - top) as i32 * line_h,
345                row_w,
346                line_h,
347            )
348        };
349        let y0 = text_y0 + (vis_lo - top) as i32 * line_h;
350        let y1 = text_y0 + (vis_hi - top + 1) as i32 * line_h;
351        let sel = Rect::new(text.x + 1, y0, row_w, y1 - y0);
352
353        let saved = painter.push_clip(text.inset(1));
354        // Stipple each selected content row; a header caught inside a cross-hunk
355        // span stays clean (it is never part of the selection).
356        for r in vis_lo..=vis_hi {
357            if self
358                .diff
359                .lines
360                .get(r)
361                .is_some_and(|l| is_selectable(l.kind))
362            {
363                stipple_rect(painter, row_band(r), SEL_OVERLAY);
364            }
365        }
366        marching_ants(painter, sel, self.ant_phase, ANT_LIGHT, ANT_DARK);
367
368        if self.selection_has_change() {
369            let label = match self.mode {
370                DiffMode::Stage => "Stage",
371                DiffMode::Unstage => "Unstage",
372                DiffMode::Plain => unreachable!(),
373            };
374            let bh = (self.font_size as i32 + 10).max(18);
375            let bw = painter.measure_text(label, self.font_size).w + 16;
376            // Bottom-right of the selection, clamped inside the text field so it
377            // stays fully visible even for a one-line or edge selection.
378            let inner = text.inset(2);
379            let bx = (sel.right() - bw - 4).min(inner.right() - bw).max(inner.x);
380            let by = (sel.bottom() - bh - 4).clamp(inner.y, (inner.bottom() - bh).max(inner.y));
381            let brect = Rect::new(bx, by, bw, bh);
382            let pressed = self.button_pressed && self.button_hot;
383            painter.button(brect, theme, pressed, false);
384            // Nudge the label down-right a pixel while held, the usual pressed
385            // affordance.
386            let label_rect = if pressed {
387                Rect::new(brect.x + 1, brect.y + 1, brect.w, brect.h)
388            } else {
389                brect
390            };
391            painter.text_centered(label_rect, label, self.font_size, theme.text);
392            self.button_rect = Some(brect);
393        }
394        painter.restore_clip(saved);
395    }
396}
397
398impl Widget for DiffView {
399    fn bounds(&self) -> Rect {
400        self.rect
401    }
402
403    fn paint(&mut self, painter: &mut Painter, theme: &Theme) {
404        self.sync_scrollbar();
405        let text = self.text_area();
406        painter.fill_rect(text, Color::WHITE);
407        painter.sunken_bevel(text, theme.highlight, theme.shadow);
408        painter.stroke_rect(text, theme.border);
409
410        let line_h = self.line_height();
411        let text_x = text.x + TEXT_PAD_X;
412        let text_y0 = text.y + TEXT_PAD_Y;
413        let row_w = (text.w - TEXT_PAD_X).max(0);
414        let visible = self.visible_rows() as usize;
415        let scroll_top = self.scroll_top();
416
417        // Clip so long lines don't bleed across the scrollbar or the border.
418        let saved = painter.push_clip(text.inset(1));
419        for row_offset in 0..visible {
420            let row = scroll_top + row_offset;
421            let Some(line) = self.diff.lines.get(row) else {
422                break;
423            };
424            let y = text_y0 + row_offset as i32 * line_h;
425            let (fg, bg) = colors_for(line.kind);
426            if let Some(bg) = bg {
427                painter.fill_rect(Rect::new(text.x + 1, y, row_w, line_h), bg);
428            }
429            let label_y = y + (line_h - self.font_size as i32) / 2 - 1;
430            painter.text_styled(
431                text_x,
432                label_y,
433                &line.text,
434                self.font_size,
435                fg,
436                FontFamily::Mono,
437                FontStyle::Regular,
438            );
439        }
440        painter.restore_clip(saved);
441
442        // Selection overlay + Stage/Unstage button float over the diff text but
443        // under the scrollbar.
444        self.paint_selection(painter, theme, text, row_w);
445
446        self.v_scrollbar.paint(painter, theme);
447    }
448
449    fn event(&mut self, event: &Event, ctx: &mut EventCtx) {
450        // Route to the scrollbar while it's dragging or being clicked.
451        if self.v_scrollbar.captures_pointer() {
452            self.v_scrollbar.event(event, ctx);
453            return;
454        }
455        // The wheel scrolls the diff whenever the pointer is anywhere over
456        // it — not just over the scrollbar gutter — without disturbing any
457        // line selection, matching native scrolled views.
458        if let Event::Scroll { pos, .. } = event {
459            if self.rect.contains(*pos) {
460                self.v_scrollbar.event(event, ctx);
461            }
462            return;
463        }
464        if let Some(pos) = event.position()
465            && self.v_scrollbar.rect().contains(pos)
466        {
467            self.v_scrollbar.event(event, ctx);
468            return;
469        }
470
471        match event {
472            Event::PointerDown {
473                pos,
474                button: MouseButton::Left,
475                modifiers,
476            } => {
477                // Press on the floating button: arm it, but don't act until the
478                // user releases over it (a real push button — releasing off it
479                // cancels). Capturing the pointer keeps the release coming here.
480                if self.button_rect.is_some_and(|r| r.contains(*pos)) {
481                    self.button_pressed = true;
482                    self.button_hot = true;
483                    ctx.request_paint();
484                    return;
485                }
486                ctx.request_focus();
487                if self.mode != DiffMode::Plain {
488                    // A click selects a content line; clicking a hunk header
489                    // selects the whole hunk. File/commit headers aren't
490                    // selectable, so clicking one clears the selection.
491                    match self
492                        .row_at(*pos)
493                        .and_then(|row| self.click_target_range(row))
494                    {
495                        Some((s, e)) if modifiers.shift && self.anchor.is_some() => {
496                            // Shift-click extends the existing selection to cover
497                            // the clicked line (or whole hunk), keeping the anchor
498                            // end fixed.
499                            let anchor = self.anchor.unwrap();
500                            self.lead = Some(if anchor <= s { e } else { s });
501                        }
502                        Some((s, e)) => {
503                            self.anchor = Some(s);
504                            self.lead = Some(e);
505                            self.dragging = true;
506                        }
507                        None => self.clear_selection(),
508                    }
509                }
510                ctx.request_paint();
511            }
512            // While the button is held, track whether the cursor is still over
513            // it so it draws sunken / pops back up as the user drags on and off.
514            Event::PointerMove { pos } if self.button_pressed => {
515                let hot = self.button_rect.is_some_and(|r| r.contains(*pos));
516                if hot != self.button_hot {
517                    self.button_hot = hot;
518                    ctx.request_paint();
519                }
520            }
521            Event::PointerMove { pos } if self.dragging => {
522                if let Some(row) = self.row_at_clamped(*pos) {
523                    self.lead = Some(row);
524                    ctx.request_paint();
525                }
526            }
527            // Releasing over the button fires the action; releasing off it just
528            // cancels the press, leaving the selection untouched.
529            Event::PointerUp {
530                pos,
531                button: MouseButton::Left,
532                ..
533            } if self.button_pressed => {
534                if self.button_rect.is_some_and(|r| r.contains(*pos)) {
535                    self.pending_action = self.body_bounds();
536                }
537                self.button_pressed = false;
538                self.button_hot = false;
539                ctx.request_paint();
540            }
541            Event::PointerUp {
542                button: MouseButton::Left,
543                ..
544            } if self.dragging => {
545                self.dragging = false;
546                ctx.request_paint();
547            }
548            Event::KeyDown { key, modifiers } if self.focused && !modifiers.has_command() => {
549                if self.mode != DiffMode::Plain
550                    && matches!(key, Key::Named(NamedKey::Escape))
551                    && self.selection_span().is_some()
552                {
553                    self.clear_selection();
554                    ctx.request_paint();
555                    return;
556                }
557                let page = (self.visible_rows() - 1).max(1);
558                let consumed = match key {
559                    Key::Named(NamedKey::Up) => {
560                        self.scroll_by(-1);
561                        true
562                    }
563                    Key::Named(NamedKey::Down) => {
564                        self.scroll_by(1);
565                        true
566                    }
567                    Key::Named(NamedKey::PageUp) => {
568                        self.scroll_by(-page);
569                        true
570                    }
571                    Key::Named(NamedKey::PageDown) => {
572                        self.scroll_by(page);
573                        true
574                    }
575                    Key::Named(NamedKey::Home) => {
576                        self.v_scrollbar.set_value(0);
577                        true
578                    }
579                    Key::Named(NamedKey::End) => {
580                        self.v_scrollbar.set_value(self.diff.lines.len() as i32);
581                        true
582                    }
583                    _ => false,
584                };
585                if consumed {
586                    ctx.request_paint();
587                }
588            }
589            Event::Tick if self.mode != DiffMode::Plain && self.body_bounds().is_some() => {
590                self.tick_accum = self.tick_accum.wrapping_add(1);
591                if self.tick_accum.is_multiple_of(ANT_TICK_DIV) {
592                    self.ant_phase = self.ant_phase.wrapping_add(1);
593                    ctx.request_paint();
594                }
595            }
596            _ => {}
597        }
598    }
599
600    fn captures_pointer(&self) -> bool {
601        self.dragging || self.button_pressed || self.v_scrollbar.captures_pointer()
602    }
603
604    fn focusable(&self) -> bool {
605        true
606    }
607
608    fn set_focused(&mut self, focused: bool) {
609        self.focused = focused;
610    }
611
612    fn wants_ticks(&self) -> bool {
613        self.mode != DiffMode::Plain && self.body_bounds().is_some()
614    }
615
616    fn layout(&mut self, bounds: Rect) {
617        self.rect = bounds;
618        self.relayout_scrollbar();
619    }
620}
621
622/// 50%-coverage checkerboard stipple of `color` over `rect`, anchored to
623/// absolute coordinates so it reads as a stable translucent screen rather than
624/// shifting with the selection. The toolkit has no alpha-blended fill, so this
625/// is how the overlay lets the diff text show through at ~50% opacity.
626fn stipple_rect(painter: &mut Painter, rect: Rect, color: Color) {
627    if rect.w <= 0 || rect.h <= 0 {
628        return;
629    }
630    for dy in 0..rect.h {
631        let y = rect.y + dy;
632        let mut dx = (rect.x + y).rem_euclid(2);
633        while dx < rect.w {
634            painter.pixel(rect.x + dx, y, color);
635            dx += 2;
636        }
637    }
638}
639
640/// A 1px "marching ants" border around `rect`: each perimeter pixel alternates
641/// between `light` and `dark` in runs of [`ANT_DASH`], shifted by `phase` so the
642/// dashes appear to crawl around the selection.
643fn marching_ants(painter: &mut Painter, rect: Rect, phase: u32, light: Color, dark: Color) {
644    if rect.w <= 1 || rect.h <= 1 {
645        return;
646    }
647    let p = phase as i32;
648    let dash = ANT_DASH.max(1);
649    let pick = |coord: i32| {
650        if (coord + p).rem_euclid(dash * 2) < dash {
651            light
652        } else {
653            dark
654        }
655    };
656    let right = rect.right() - 1;
657    let bottom = rect.bottom() - 1;
658    let mut x = rect.x;
659    while x <= right {
660        painter.pixel(x, rect.y, pick(x));
661        painter.pixel(x, bottom, pick(x));
662        x += 1;
663    }
664    let mut y = rect.y;
665    while y <= bottom {
666        painter.pixel(rect.x, y, pick(y));
667        painter.pixel(right, y, pick(y));
668        y += 1;
669    }
670}
671
672/// Whether a diff row can be part of a line selection — every content row, but
673/// not the file / hunk / commit header rows (clicking a hunk header selects the
674/// whole hunk, a file header clears the selection; the headers themselves never
675/// highlight).
676fn is_selectable(kind: DiffLineKind) -> bool {
677    !matches!(
678        kind,
679        DiffLineKind::FileHeader | DiffLineKind::HunkHeader | DiffLineKind::CommitHeader
680    )
681}
682
683/// Foreground color and optional row background tint for a diff line kind.
684fn colors_for(kind: DiffLineKind) -> (Color, Option<Color>) {
685    match kind {
686        DiffLineKind::CommitHeader => (COMMIT_FG, Some(COMMIT_BG)),
687        DiffLineKind::Addition => (ADD_FG, Some(ADD_BG)),
688        DiffLineKind::Deletion => (DEL_FG, Some(DEL_BG)),
689        DiffLineKind::HunkHeader => (HUNK_FG, Some(HUNK_BG)),
690        DiffLineKind::FileHeader => (FILE_FG, Some(FILE_BG)),
691        DiffLineKind::Meta => (META_FG, None),
692        DiffLineKind::Context => (CONTEXT_FG, None),
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699    use crate::backend::DiffLine;
700    use saudade::mock::MockBackend;
701    use saudade::{Event, Modifiers, Point};
702
703    const W: i32 = 320;
704    const H: i32 = 200;
705
706    fn down(x: i32, y: i32) -> Event {
707        Event::PointerDown {
708            pos: Point::new(x, y),
709            button: MouseButton::Left,
710            modifiers: Modifiers::default(),
711        }
712    }
713    fn up(x: i32, y: i32) -> Event {
714        Event::PointerUp {
715            pos: Point::new(x, y),
716            button: MouseButton::Left,
717            modifiers: Modifiers::default(),
718        }
719    }
720
721    /// rows: 0 file header, 1 hunk header, 2 context, 3 add, 4 add, 5 context.
722    fn sample() -> Diff {
723        use DiffLineKind::*;
724        Diff {
725            lines: [
726                (FileHeader, "diff --git a/f b/f"),
727                (HunkHeader, "@@ -1,2 +1,4 @@"),
728                (Context, " ctx"),
729                (Addition, "+one"),
730                (Addition, "+two"),
731                (Context, " ctx2"),
732            ]
733            .iter()
734            .map(|(k, t)| DiffLine::new(*k, t.to_string()))
735            .collect(),
736        }
737    }
738
739    /// Center y of diff row `r` for a widget anchored at (0,0): rows start at
740    /// `TEXT_PAD_Y` and are `line_height` (font 12 + 4 = 16) tall.
741    fn row_y(r: i32) -> i32 {
742        TEXT_PAD_Y + r * 16 + 8
743    }
744
745    fn staged_view() -> (MockBackend, DiffView) {
746        let be = MockBackend::new(W, H).with_scale(1.0);
747        let mut dv = DiffView::new(Rect::new(0, 0, W, H));
748        dv.set_mode(DiffMode::Stage);
749        dv.set_diff(sample());
750        dv.layout(Rect::new(0, 0, W, H));
751        let _ = be.render(&mut dv);
752        (be, dv)
753    }
754
755    fn scroll(x: i32, y: i32, delta_y: f32) -> Event {
756        Event::Scroll {
757            pos: Point::new(x, y),
758            delta_x: 0.0,
759            delta_y,
760        }
761    }
762
763    /// Like [`staged_view`] but with the sample diff padded by enough trailing
764    /// context lines that the view can actually scroll.
765    fn long_staged_view() -> (MockBackend, DiffView) {
766        let mut diff = sample();
767        diff.lines.extend(
768            (0..40).map(|i| DiffLine::new(DiffLineKind::Context, format!(" pad {i}"))),
769        );
770        let be = MockBackend::new(W, H).with_scale(1.0);
771        let mut dv = DiffView::new(Rect::new(0, 0, W, H));
772        dv.set_mode(DiffMode::Stage);
773        dv.set_diff(diff);
774        dv.layout(Rect::new(0, 0, W, H));
775        let _ = be.render(&mut dv);
776        (be, dv)
777    }
778
779    #[test]
780    fn the_wheel_scrolls_the_diff_without_touching_the_selection() {
781        let (be, mut dv) = long_staged_view();
782        // Select an addition first, so we can check the wheel leaves it alone.
783        be.dispatch(&mut dv, &down(10, row_y(3)));
784        be.dispatch(&mut dv, &up(10, row_y(3)));
785        assert_eq!(dv.body_bounds(), Some((3, 3)));
786        assert_eq!(dv.scroll_top(), 0);
787
788        be.dispatch(&mut dv, &scroll(W / 2, H / 2, 3.0));
789        assert_eq!(dv.scroll_top(), 3, "one notch scrolls three lines down");
790        assert_eq!(dv.body_bounds(), Some((3, 3)), "selection is untouched");
791
792        be.dispatch(&mut dv, &scroll(W / 2, H / 2, -3.0));
793        assert_eq!(dv.scroll_top(), 0, "scrolling back returns to the top");
794    }
795
796    #[test]
797    fn a_wheel_event_outside_the_diff_is_ignored() {
798        let (be, mut dv) = long_staged_view();
799        be.dispatch(&mut dv, &scroll(W + 10, H + 10, 3.0));
800        assert_eq!(dv.scroll_top(), 0);
801    }
802
803    #[test]
804    fn clicking_a_hunk_header_selects_the_whole_hunk() {
805        let (be, mut dv) = staged_view();
806        be.dispatch(&mut dv, &down(10, row_y(1))); // the @@ hunk header
807        be.dispatch(&mut dv, &up(10, row_y(1)));
808        // The hunk's body is rows 2..=5; the header itself is excluded.
809        assert_eq!(dv.body_bounds(), Some((2, 5)));
810    }
811
812    #[test]
813    fn clicking_a_file_header_clears_the_selection() {
814        let (be, mut dv) = staged_view();
815        be.dispatch(&mut dv, &down(10, row_y(3))); // select an addition
816        be.dispatch(&mut dv, &up(10, row_y(3)));
817        assert_eq!(dv.body_bounds(), Some((3, 3)));
818        be.dispatch(&mut dv, &down(10, row_y(0))); // click the file header
819        be.dispatch(&mut dv, &up(10, row_y(0)));
820        assert_eq!(dv.body_bounds(), None, "file-header click deselects");
821        assert!(dv.anchor.is_none());
822    }
823
824    #[test]
825    fn button_fires_only_on_release_over_it() {
826        let (be, mut dv) = staged_view();
827        // Select an addition so the Stage button shows, then re-render to place
828        // (and cache) the button rect.
829        be.dispatch(&mut dv, &down(10, row_y(3)));
830        be.dispatch(&mut dv, &up(10, row_y(3)));
831        let _ = be.render(&mut dv);
832        let b = dv.button_rect.expect("button shows for a change selection");
833        let (cx, cy) = (b.x + b.w / 2, b.y + b.h / 2);
834
835        // Press on the button but release away from it: nothing happens, and the
836        // selection is untouched.
837        be.dispatch(&mut dv, &down(cx, cy));
838        be.dispatch(&mut dv, &up(2, row_y(2)));
839        assert!(dv.take_action().is_none(), "release off the button cancels");
840        assert_eq!(
841            dv.body_bounds(),
842            Some((3, 3)),
843            "selection survives a cancel"
844        );
845        assert!(!dv.button_pressed);
846
847        // Press and release over the button: the action fires with the range.
848        be.dispatch(&mut dv, &down(cx, cy));
849        be.dispatch(&mut dv, &up(cx, cy));
850        assert_eq!(
851            dv.take_action(),
852            Some((3, 3)),
853            "release over the button fires"
854        );
855    }
856}