1use 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
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.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 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 if self.v_scrollbar.captures_pointer() {
452 self.v_scrollbar.event(event, ctx);
453 return;
454 }
455 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 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 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 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 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 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
622fn 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
640fn 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
672fn is_selectable(kind: DiffLineKind) -> bool {
677 !matches!(
678 kind,
679 DiffLineKind::FileHeader | DiffLineKind::HunkHeader | DiffLineKind::CommitHeader
680 )
681}
682
683fn 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 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 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 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 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))); be.dispatch(&mut dv, &up(10, row_y(1)));
808 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))); 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))); 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 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 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 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}