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, Key, MouseButton, NamedKey, Painter, Point, Rect, SCROLLBAR_THICKNESS,
21    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.mono_text(text_x, label_y, &line.text, self.font_size, fg);
431        }
432        painter.restore_clip(saved);
433
434        // Selection overlay + Stage/Unstage button float over the diff text but
435        // under the scrollbar.
436        self.paint_selection(painter, theme, text, row_w);
437
438        self.v_scrollbar.paint(painter, theme);
439    }
440
441    fn event(&mut self, event: &Event, ctx: &mut EventCtx) {
442        // Route to the scrollbar while it's dragging or being clicked.
443        if self.v_scrollbar.captures_pointer() {
444            self.v_scrollbar.event(event, ctx);
445            return;
446        }
447        if let Some(pos) = event.position()
448            && self.v_scrollbar.rect().contains(pos)
449        {
450            self.v_scrollbar.event(event, ctx);
451            return;
452        }
453
454        match event {
455            Event::PointerDown {
456                pos,
457                button: MouseButton::Left,
458                modifiers,
459            } => {
460                // Press on the floating button: arm it, but don't act until the
461                // user releases over it (a real push button — releasing off it
462                // cancels). Capturing the pointer keeps the release coming here.
463                if self.button_rect.is_some_and(|r| r.contains(*pos)) {
464                    self.button_pressed = true;
465                    self.button_hot = true;
466                    ctx.request_paint();
467                    return;
468                }
469                ctx.request_focus();
470                if self.mode != DiffMode::Plain {
471                    // A click selects a content line; clicking a hunk header
472                    // selects the whole hunk. File/commit headers aren't
473                    // selectable, so clicking one clears the selection.
474                    match self
475                        .row_at(*pos)
476                        .and_then(|row| self.click_target_range(row))
477                    {
478                        Some((s, e)) if modifiers.shift && self.anchor.is_some() => {
479                            // Shift-click extends the existing selection to cover
480                            // the clicked line (or whole hunk), keeping the anchor
481                            // end fixed.
482                            let anchor = self.anchor.unwrap();
483                            self.lead = Some(if anchor <= s { e } else { s });
484                        }
485                        Some((s, e)) => {
486                            self.anchor = Some(s);
487                            self.lead = Some(e);
488                            self.dragging = true;
489                        }
490                        None => self.clear_selection(),
491                    }
492                }
493                ctx.request_paint();
494            }
495            // While the button is held, track whether the cursor is still over
496            // it so it draws sunken / pops back up as the user drags on and off.
497            Event::PointerMove { pos } if self.button_pressed => {
498                let hot = self.button_rect.is_some_and(|r| r.contains(*pos));
499                if hot != self.button_hot {
500                    self.button_hot = hot;
501                    ctx.request_paint();
502                }
503            }
504            Event::PointerMove { pos } if self.dragging => {
505                if let Some(row) = self.row_at_clamped(*pos) {
506                    self.lead = Some(row);
507                    ctx.request_paint();
508                }
509            }
510            // Releasing over the button fires the action; releasing off it just
511            // cancels the press, leaving the selection untouched.
512            Event::PointerUp {
513                pos,
514                button: MouseButton::Left,
515                ..
516            } if self.button_pressed => {
517                if self.button_rect.is_some_and(|r| r.contains(*pos)) {
518                    self.pending_action = self.body_bounds();
519                }
520                self.button_pressed = false;
521                self.button_hot = false;
522                ctx.request_paint();
523            }
524            Event::PointerUp {
525                button: MouseButton::Left,
526                ..
527            } if self.dragging => {
528                self.dragging = false;
529                ctx.request_paint();
530            }
531            Event::KeyDown { key, modifiers } if self.focused && !modifiers.has_command() => {
532                if self.mode != DiffMode::Plain
533                    && matches!(key, Key::Named(NamedKey::Escape))
534                    && self.selection_span().is_some()
535                {
536                    self.clear_selection();
537                    ctx.request_paint();
538                    return;
539                }
540                let page = (self.visible_rows() - 1).max(1);
541                let consumed = match key {
542                    Key::Named(NamedKey::Up) => {
543                        self.scroll_by(-1);
544                        true
545                    }
546                    Key::Named(NamedKey::Down) => {
547                        self.scroll_by(1);
548                        true
549                    }
550                    Key::Named(NamedKey::PageUp) => {
551                        self.scroll_by(-page);
552                        true
553                    }
554                    Key::Named(NamedKey::PageDown) => {
555                        self.scroll_by(page);
556                        true
557                    }
558                    Key::Named(NamedKey::Home) => {
559                        self.v_scrollbar.set_value(0);
560                        true
561                    }
562                    Key::Named(NamedKey::End) => {
563                        self.v_scrollbar.set_value(self.diff.lines.len() as i32);
564                        true
565                    }
566                    _ => false,
567                };
568                if consumed {
569                    ctx.request_paint();
570                }
571            }
572            Event::Tick if self.mode != DiffMode::Plain && self.body_bounds().is_some() => {
573                self.tick_accum = self.tick_accum.wrapping_add(1);
574                if self.tick_accum.is_multiple_of(ANT_TICK_DIV) {
575                    self.ant_phase = self.ant_phase.wrapping_add(1);
576                    ctx.request_paint();
577                }
578            }
579            _ => {}
580        }
581    }
582
583    fn captures_pointer(&self) -> bool {
584        self.dragging || self.button_pressed || self.v_scrollbar.captures_pointer()
585    }
586
587    fn focusable(&self) -> bool {
588        true
589    }
590
591    fn set_focused(&mut self, focused: bool) {
592        self.focused = focused;
593    }
594
595    fn wants_ticks(&self) -> bool {
596        self.mode != DiffMode::Plain && self.body_bounds().is_some()
597    }
598
599    fn layout(&mut self, bounds: Rect) {
600        self.rect = bounds;
601        self.relayout_scrollbar();
602    }
603}
604
605/// 50%-coverage checkerboard stipple of `color` over `rect`, anchored to
606/// absolute coordinates so it reads as a stable translucent screen rather than
607/// shifting with the selection. The toolkit has no alpha-blended fill, so this
608/// is how the overlay lets the diff text show through at ~50% opacity.
609fn stipple_rect(painter: &mut Painter, rect: Rect, color: Color) {
610    if rect.w <= 0 || rect.h <= 0 {
611        return;
612    }
613    for dy in 0..rect.h {
614        let y = rect.y + dy;
615        let mut dx = (rect.x + y).rem_euclid(2);
616        while dx < rect.w {
617            painter.pixel(rect.x + dx, y, color);
618            dx += 2;
619        }
620    }
621}
622
623/// A 1px "marching ants" border around `rect`: each perimeter pixel alternates
624/// between `light` and `dark` in runs of [`ANT_DASH`], shifted by `phase` so the
625/// dashes appear to crawl around the selection.
626fn marching_ants(painter: &mut Painter, rect: Rect, phase: u32, light: Color, dark: Color) {
627    if rect.w <= 1 || rect.h <= 1 {
628        return;
629    }
630    let p = phase as i32;
631    let dash = ANT_DASH.max(1);
632    let pick = |coord: i32| {
633        if (coord + p).rem_euclid(dash * 2) < dash {
634            light
635        } else {
636            dark
637        }
638    };
639    let right = rect.right() - 1;
640    let bottom = rect.bottom() - 1;
641    let mut x = rect.x;
642    while x <= right {
643        painter.pixel(x, rect.y, pick(x));
644        painter.pixel(x, bottom, pick(x));
645        x += 1;
646    }
647    let mut y = rect.y;
648    while y <= bottom {
649        painter.pixel(rect.x, y, pick(y));
650        painter.pixel(right, y, pick(y));
651        y += 1;
652    }
653}
654
655/// Whether a diff row can be part of a line selection — every content row, but
656/// not the file / hunk / commit header rows (clicking a hunk header selects the
657/// whole hunk, a file header clears the selection; the headers themselves never
658/// highlight).
659fn is_selectable(kind: DiffLineKind) -> bool {
660    !matches!(
661        kind,
662        DiffLineKind::FileHeader | DiffLineKind::HunkHeader | DiffLineKind::CommitHeader
663    )
664}
665
666/// Foreground color and optional row background tint for a diff line kind.
667fn colors_for(kind: DiffLineKind) -> (Color, Option<Color>) {
668    match kind {
669        DiffLineKind::CommitHeader => (COMMIT_FG, Some(COMMIT_BG)),
670        DiffLineKind::Addition => (ADD_FG, Some(ADD_BG)),
671        DiffLineKind::Deletion => (DEL_FG, Some(DEL_BG)),
672        DiffLineKind::HunkHeader => (HUNK_FG, Some(HUNK_BG)),
673        DiffLineKind::FileHeader => (FILE_FG, Some(FILE_BG)),
674        DiffLineKind::Meta => (META_FG, None),
675        DiffLineKind::Context => (CONTEXT_FG, None),
676    }
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682    use crate::backend::DiffLine;
683    use saudade::mock::MockBackend;
684    use saudade::{Event, Modifiers, Point};
685
686    const W: i32 = 320;
687    const H: i32 = 200;
688
689    fn down(x: i32, y: i32) -> Event {
690        Event::PointerDown {
691            pos: Point::new(x, y),
692            button: MouseButton::Left,
693            modifiers: Modifiers::default(),
694        }
695    }
696    fn up(x: i32, y: i32) -> Event {
697        Event::PointerUp {
698            pos: Point::new(x, y),
699            button: MouseButton::Left,
700            modifiers: Modifiers::default(),
701        }
702    }
703
704    /// rows: 0 file header, 1 hunk header, 2 context, 3 add, 4 add, 5 context.
705    fn sample() -> Diff {
706        use DiffLineKind::*;
707        Diff {
708            lines: [
709                (FileHeader, "diff --git a/f b/f"),
710                (HunkHeader, "@@ -1,2 +1,4 @@"),
711                (Context, " ctx"),
712                (Addition, "+one"),
713                (Addition, "+two"),
714                (Context, " ctx2"),
715            ]
716            .iter()
717            .map(|(k, t)| DiffLine::new(*k, t.to_string()))
718            .collect(),
719        }
720    }
721
722    /// Center y of diff row `r` for a widget anchored at (0,0): rows start at
723    /// `TEXT_PAD_Y` and are `line_height` (font 12 + 4 = 16) tall.
724    fn row_y(r: i32) -> i32 {
725        TEXT_PAD_Y + r * 16 + 8
726    }
727
728    fn staged_view() -> (MockBackend, DiffView) {
729        let be = MockBackend::new(W, H).with_scale(1.0);
730        let mut dv = DiffView::new(Rect::new(0, 0, W, H));
731        dv.set_mode(DiffMode::Stage);
732        dv.set_diff(sample());
733        dv.layout(Rect::new(0, 0, W, H));
734        let _ = be.render(&mut dv);
735        (be, dv)
736    }
737
738    #[test]
739    fn clicking_a_hunk_header_selects_the_whole_hunk() {
740        let (be, mut dv) = staged_view();
741        be.dispatch(&mut dv, &down(10, row_y(1))); // the @@ hunk header
742        be.dispatch(&mut dv, &up(10, row_y(1)));
743        // The hunk's body is rows 2..=5; the header itself is excluded.
744        assert_eq!(dv.body_bounds(), Some((2, 5)));
745    }
746
747    #[test]
748    fn clicking_a_file_header_clears_the_selection() {
749        let (be, mut dv) = staged_view();
750        be.dispatch(&mut dv, &down(10, row_y(3))); // select an addition
751        be.dispatch(&mut dv, &up(10, row_y(3)));
752        assert_eq!(dv.body_bounds(), Some((3, 3)));
753        be.dispatch(&mut dv, &down(10, row_y(0))); // click the file header
754        be.dispatch(&mut dv, &up(10, row_y(0)));
755        assert_eq!(dv.body_bounds(), None, "file-header click deselects");
756        assert!(dv.anchor.is_none());
757    }
758
759    #[test]
760    fn button_fires_only_on_release_over_it() {
761        let (be, mut dv) = staged_view();
762        // Select an addition so the Stage button shows, then re-render to place
763        // (and cache) the button rect.
764        be.dispatch(&mut dv, &down(10, row_y(3)));
765        be.dispatch(&mut dv, &up(10, row_y(3)));
766        let _ = be.render(&mut dv);
767        let b = dv.button_rect.expect("button shows for a change selection");
768        let (cx, cy) = (b.x + b.w / 2, b.y + b.h / 2);
769
770        // Press on the button but release away from it: nothing happens, and the
771        // selection is untouched.
772        be.dispatch(&mut dv, &down(cx, cy));
773        be.dispatch(&mut dv, &up(2, row_y(2)));
774        assert!(dv.take_action().is_none(), "release off the button cancels");
775        assert_eq!(
776            dv.body_bounds(),
777            Some((3, 3)),
778            "selection survives a cancel"
779        );
780        assert!(!dv.button_pressed);
781
782        // Press and release over the button: the action fires with the range.
783        be.dispatch(&mut dv, &down(cx, cy));
784        be.dispatch(&mut dv, &up(cx, cy));
785        assert_eq!(
786            dv.take_action(),
787            Some((3, 3)),
788            "release over the button fires"
789        );
790    }
791}