1use 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
29const 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
43const SEL_OVERLAY: Color = Color::rgb(0x33, 0x66, 0xCC);
49const ANT_LIGHT: Color = Color::WHITE;
50const ANT_DARK: Color = Color::rgb(0x00, 0x33, 0x99);
51const ANT_DASH: i32 = 3;
53const ANT_TICK_DIV: u32 = 3;
56
57#[derive(Clone, Copy, PartialEq, Eq)]
61pub enum DiffMode {
62 Plain,
64 Stage,
66 Unstage,
68}
69
70pub struct DiffView {
72 rect: Rect,
73 diff: Diff,
74 v_scrollbar: ScrollBar,
75 focused: bool,
76 font_size: f32,
77 mode: DiffMode,
78 anchor: Option<usize>,
80 lead: Option<usize>,
82 dragging: bool,
84 ant_phase: u32,
86 tick_accum: u32,
88 pending_action: Option<(usize, usize)>,
91 button_rect: Option<Rect>,
93 button_pressed: bool,
97 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 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 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 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 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 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 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 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 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 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 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 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 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; }
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 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 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 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 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 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 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 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 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 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 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 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
605fn 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
623fn 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
655fn is_selectable(kind: DiffLineKind) -> bool {
660 !matches!(
661 kind,
662 DiffLineKind::FileHeader | DiffLineKind::HunkHeader | DiffLineKind::CommitHeader
663 )
664}
665
666fn 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 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 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))); be.dispatch(&mut dv, &up(10, row_y(1)));
743 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))); 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))); 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 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 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 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}