Skip to main content

gpui_liveplot/gpui_backend/
view.rs

1use std::sync::{Arc, RwLock};
2use std::time::{Duration, Instant};
3
4use gpui::prelude::*;
5use gpui::{
6    MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollWheelEvent,
7    StatefulInteractiveElement, Window, canvas, div, px,
8};
9
10use crate::geom::{Point as DataPoint, ScreenPoint, ScreenRect};
11use crate::interaction::{
12    HitRegion, pan_viewport, toggle_pin, zoom_factor_from_drag, zoom_to_rect, zoom_viewport,
13};
14use crate::plot::Plot;
15use crate::transform::Transform;
16use crate::view::{Range, Viewport};
17
18use super::config::PlotViewConfig;
19use super::constants::DOUBLE_CLICK_PIN_GRACE_MS;
20use super::frame::build_frame;
21use super::geometry::{distance_sq, normalized_rect};
22use super::hover::{compute_hover_target, hover_target_within_threshold};
23use super::link::{LinkBinding, PlotLinkGroup, PlotLinkOptions, ViewSyncKind};
24use super::paint::{paint_frame, to_hsla};
25use super::state::{ClickState, DragMode, DragState, PinToggle, PlotUiState};
26
27/// A GPUI view that renders a [`Plot`] with interactive controls.
28///
29/// This view handles pan/zoom/box-zoom, hover readouts, and pin interactions
30/// while delegating data management to the underlying [`Plot`].
31#[derive(Debug, Clone)]
32pub struct PlotView {
33    plot: Arc<RwLock<Plot>>,
34    state: Arc<RwLock<PlotUiState>>,
35    config: PlotViewConfig,
36    link: Option<LinkBinding>,
37}
38
39impl PlotView {
40    /// Create a new GPUI plot view for the given plot.
41    ///
42    /// Uses the default [`PlotViewConfig`].
43    pub fn new(plot: Plot) -> Self {
44        Self {
45            plot: Arc::new(RwLock::new(plot)),
46            state: Arc::new(RwLock::new(PlotUiState::default())),
47            config: PlotViewConfig::default(),
48            link: None,
49        }
50    }
51
52    /// Create a new GPUI plot view with a custom configuration.
53    pub fn with_config(plot: Plot, config: PlotViewConfig) -> Self {
54        Self {
55            plot: Arc::new(RwLock::new(plot)),
56            state: Arc::new(RwLock::new(PlotUiState::default())),
57            config,
58            link: None,
59        }
60    }
61
62    /// Attach this view to a multi-plot link group.
63    ///
64    /// Link groups synchronize viewport/cursor/brush state between views.
65    pub fn with_link_group(mut self, group: PlotLinkGroup, options: PlotLinkOptions) -> Self {
66        self.link = Some(LinkBinding {
67            member_id: group.register_member(),
68            group,
69            options,
70        });
71        self
72    }
73
74    /// Get a handle for mutating the underlying plot.
75    ///
76    /// This is useful for streaming updates from async tasks.
77    pub fn plot_handle(&self) -> PlotHandle {
78        PlotHandle {
79            plot: Arc::clone(&self.plot),
80        }
81    }
82
83    fn publish_manual_view_link(&self, viewport: Viewport) {
84        let Some(link) = self.link.as_ref() else {
85            return;
86        };
87        link.group.publish_manual_view(
88            link.member_id,
89            viewport,
90            link.options.link_x,
91            link.options.link_y,
92        );
93    }
94
95    fn publish_reset_link(&self) {
96        let Some(link) = self.link.as_ref() else {
97            return;
98        };
99        if link.options.link_reset {
100            link.group.publish_reset(link.member_id);
101        }
102    }
103
104    fn publish_cursor_link(&self, x: Option<f64>) {
105        let Some(link) = self.link.as_ref() else {
106            return;
107        };
108        if link.options.link_cursor {
109            link.group.publish_cursor_x(link.member_id, x);
110        }
111    }
112
113    fn publish_brush_link(&self, x_range: Option<Range>) {
114        let Some(link) = self.link.as_ref() else {
115            return;
116        };
117        if link.options.link_brush {
118            link.group.publish_brush_x(link.member_id, x_range);
119        }
120    }
121
122    fn apply_manual_view_with_link(
123        &self,
124        plot: &mut Plot,
125        state: &mut PlotUiState,
126        rect: ScreenRect,
127        viewport: Viewport,
128    ) {
129        apply_manual_view(plot, state, rect, viewport);
130        state.linked_brush_x = None;
131        self.publish_manual_view_link(viewport);
132        self.publish_brush_link(None);
133    }
134
135    fn on_mouse_down(&mut self, ev: &MouseDownEvent, cx: &mut Context<Self>) {
136        let pos = screen_point(ev.position);
137        let mut state = self.state.write().expect("plot state lock");
138        state.last_cursor = Some(pos);
139
140        if let Some(series_id) = state.legend_hit(pos) {
141            if ev.button == MouseButton::Left && ev.click_count == 1 {
142                if let Ok(mut plot) = self.plot.write() {
143                    if let Some(series) = plot
144                        .series_mut()
145                        .iter_mut()
146                        .find(|series| series.id() == series_id)
147                    {
148                        series.set_visible(!series.is_visible());
149                    }
150                }
151            }
152            state.clear_interaction();
153            state.hover = None;
154            state.hover_target = None;
155            cx.notify();
156            return;
157        }
158
159        let region = state.regions.hit_test(pos);
160        if ev.button == MouseButton::Left && ev.click_count >= 2 && region == HitRegion::Plot {
161            let last_toggle = state.last_pin_toggle.take();
162            if let Ok(mut plot) = self.plot.write() {
163                if let Some(last_toggle) = last_toggle {
164                    if last_toggle.at.elapsed() <= Duration::from_millis(DOUBLE_CLICK_PIN_GRACE_MS)
165                        && distance_sq(last_toggle.screen_pos, pos)
166                            <= self.config.pin_threshold_px.powi(2)
167                    {
168                        revert_pin_toggle(&mut plot, last_toggle);
169                    }
170                }
171                plot.reset_view();
172                state.linked_brush_x = None;
173                self.publish_reset_link();
174                self.publish_brush_link(None);
175            }
176            state.clear_interaction();
177            cx.notify();
178            return;
179        }
180
181        state.pending_click = Some(ClickState {
182            region,
183            button: ev.button,
184        });
185
186        match (ev.button, region) {
187            (MouseButton::Left, HitRegion::XAxis) => {
188                state.drag = Some(DragState::new(DragMode::ZoomX, pos, true));
189            }
190            (MouseButton::Left, HitRegion::YAxis) => {
191                state.drag = Some(DragState::new(DragMode::ZoomY, pos, true));
192            }
193            (MouseButton::Left, HitRegion::Plot) => {
194                state.drag = Some(DragState::new(DragMode::Pan, pos, false));
195            }
196            (MouseButton::Right, HitRegion::Plot) => {
197                state.drag = Some(DragState::new(DragMode::ZoomRect, pos, true));
198                state.selection_rect = Some(ScreenRect::new(pos, pos));
199            }
200            _ => {}
201        }
202
203        cx.notify();
204    }
205
206    fn on_mouse_move(&mut self, ev: &MouseMoveEvent, cx: &mut Context<Self>) {
207        let pos = screen_point(ev.position);
208        let mut state = self.state.write().expect("plot state lock");
209        state.last_cursor = Some(pos);
210
211        if state.legend_hit(pos).is_some() {
212            state.hover = None;
213        } else if state.regions.hit_test(pos) == HitRegion::Plot {
214            state.hover = Some(pos);
215        } else {
216            state.hover = None;
217        }
218        let linked_cursor_x = state.hover.and_then(|_| {
219            state
220                .transform
221                .as_ref()
222                .and_then(|transform| transform.screen_to_data(pos))
223                .map(|point| point.x)
224        });
225        self.publish_cursor_link(linked_cursor_x);
226
227        let Some(mut drag) = state.drag.clone() else {
228            cx.notify();
229            return;
230        };
231
232        if !is_drag_button_held(drag.mode, ev.pressed_button) {
233            state.clear_interaction();
234            self.publish_cursor_link(None);
235            cx.notify();
236            return;
237        }
238
239        let moved_sq = distance_sq(drag.start, pos);
240        if !drag.active && moved_sq > self.config.drag_threshold_px.powi(2) {
241            drag.active = true;
242        }
243
244        if !drag.active {
245            state.drag = Some(drag);
246            cx.notify();
247            return;
248        }
249
250        let delta = ScreenPoint::new(pos.x - drag.last.x, pos.y - drag.last.y);
251        let plot_rect = state.plot_rect;
252        let transform = state.transform.clone();
253
254        match drag.mode {
255            DragMode::Pan => {
256                if let (Some(rect), Some(transform)) = (plot_rect, transform) {
257                    if let Ok(mut plot) = self.plot.write() {
258                        if let Some(viewport) = plot.viewport() {
259                            if let Some(next) = pan_viewport(viewport, delta, &transform) {
260                                self.apply_manual_view_with_link(&mut plot, &mut state, rect, next);
261                            }
262                        }
263                    }
264                }
265            }
266            DragMode::ZoomRect => {
267                state.selection_rect = Some(ScreenRect::new(drag.start, pos));
268            }
269            DragMode::ZoomX => {
270                if let (Some(rect), Some(transform)) = (plot_rect, transform) {
271                    let axis_pixels = rect.width().max(1.0);
272                    let factor = zoom_factor_from_drag(delta.x, axis_pixels);
273                    if let Ok(mut plot) = self.plot.write() {
274                        if let Some(viewport) = plot.viewport() {
275                            let center = transform
276                                .screen_to_data(pos)
277                                .unwrap_or_else(|| viewport.x_center());
278                            let next = zoom_viewport(viewport, center, factor, 1.0);
279                            self.apply_manual_view_with_link(&mut plot, &mut state, rect, next);
280                        }
281                    }
282                }
283            }
284            DragMode::ZoomY => {
285                if let (Some(rect), Some(transform)) = (plot_rect, transform) {
286                    let axis_pixels = rect.height().max(1.0);
287                    let factor = zoom_factor_from_drag(-delta.y, axis_pixels);
288                    if let Ok(mut plot) = self.plot.write() {
289                        if let Some(viewport) = plot.viewport() {
290                            let center = transform
291                                .screen_to_data(pos)
292                                .unwrap_or_else(|| viewport.y_center());
293                            let next = zoom_viewport(viewport, center, 1.0, factor);
294                            self.apply_manual_view_with_link(&mut plot, &mut state, rect, next);
295                        }
296                    }
297                }
298            }
299        }
300
301        drag.last = pos;
302        state.drag = Some(drag);
303        state.pending_click = None;
304        cx.notify();
305    }
306
307    fn on_hover_state_change(&mut self, hovered: bool, window: &Window, cx: &mut Context<Self>) {
308        if hovered {
309            return;
310        }
311
312        let cursor = screen_point(window.mouse_position());
313        let mut state = self.state.write().expect("plot state lock");
314        let still_inside = state.legend_hit(cursor).is_some()
315            || state.regions.hit_test(cursor) != HitRegion::Outside;
316        if still_inside {
317            return;
318        }
319
320        let changed = state.hover.take().is_some() || state.hover_target.take().is_some();
321        state.last_cursor = None;
322        drop(state);
323
324        self.publish_cursor_link(None);
325        if changed {
326            cx.notify();
327        }
328    }
329
330    fn on_mouse_up(&mut self, ev: &MouseUpEvent, cx: &mut Context<Self>) {
331        let pos = screen_point(ev.position);
332        let mut state = self.state.write().expect("plot state lock");
333        let drag = state.drag.clone();
334
335        if let Some(drag_state) = drag.as_ref() {
336            if drag_state.active && drag_state.mode == DragMode::ZoomRect {
337                if let (Some(rect), Some(transform)) =
338                    (state.selection_rect.take(), state.transform.clone())
339                {
340                    let rect = normalized_rect(rect);
341                    if let Ok(mut plot) = self.plot.write() {
342                        if let Some(viewport) = plot.viewport() {
343                            if let Some(next) = zoom_to_rect(viewport, rect, &transform) {
344                                self.apply_manual_view_with_link(
345                                    &mut plot,
346                                    &mut state,
347                                    transform.screen(),
348                                    next,
349                                );
350                                self.publish_brush_link(Some(next.x));
351                            }
352                        }
353                    }
354                }
355            }
356        }
357
358        let click = state.pending_click.take();
359        let should_toggle = click.as_ref().is_some_and(|click| {
360            click.button == MouseButton::Left && click.region == HitRegion::Plot
361        }) && drag.as_ref().is_none_or(|drag| !drag.active)
362            && ev.click_count == 1;
363
364        if should_toggle {
365            if let Some(transform) = state.transform.clone() {
366                if let Ok(mut plot) = self.plot.write() {
367                    let target = state
368                        .hover_target
369                        .filter(|target| hover_target_within_threshold(target, pos, &self.config))
370                        .or_else(|| {
371                            compute_hover_target(
372                                &plot,
373                                &transform,
374                                pos,
375                                state.plot_rect,
376                                self.config.pin_threshold_px,
377                                self.config.unpin_threshold_px,
378                            )
379                        });
380
381                    if let Some(target) = target {
382                        let added = toggle_pin(plot.pins_mut(), target.pin);
383                        let now = Instant::now();
384                        state.last_pin_toggle = Some(PinToggle {
385                            pin: target.pin,
386                            added,
387                            at: now,
388                            screen_pos: target.screen,
389                        });
390                    }
391                }
392            }
393        } else if ev.click_count > 1 {
394            state.last_pin_toggle = None;
395        }
396
397        state.drag = None;
398        state.selection_rect = None;
399        self.publish_cursor_link(None);
400        cx.notify();
401    }
402
403    fn on_mouse_up_out(&mut self, _ev: &MouseUpEvent, cx: &mut Context<Self>) {
404        let mut state = self.state.write().expect("plot state lock");
405        state.clear_interaction();
406        self.publish_cursor_link(None);
407        cx.notify();
408    }
409
410    fn on_scroll(&mut self, ev: &ScrollWheelEvent, _window: &Window, cx: &mut Context<Self>) {
411        let pos = screen_point(ev.position);
412        let mut state = self.state.write().expect("plot state lock");
413        if state.legend_hit(pos).is_some() {
414            return;
415        }
416        let region = state.regions.hit_test(pos);
417        let Some(transform) = state.transform.clone() else {
418            return;
419        };
420
421        let line_height = px(16.0);
422        let delta = ev.delta.pixel_delta(line_height);
423        let factor = scroll_zoom_factor(f32::from(delta.y));
424        if factor == 1.0 {
425            return;
426        }
427
428        if let Ok(mut plot) = self.plot.write() {
429            if let Some(viewport) = plot.viewport() {
430                let center = transform
431                    .screen_to_data(pos)
432                    .unwrap_or_else(|| viewport.center());
433                let (factor_x, factor_y) = match region {
434                    HitRegion::XAxis => (factor, 1.0),
435                    HitRegion::YAxis => (1.0, factor),
436                    HitRegion::Plot => (factor, factor),
437                    HitRegion::Outside => (1.0, 1.0),
438                };
439                if factor_x != 1.0 || factor_y != 1.0 {
440                    let next = zoom_viewport(viewport, center, factor_x, factor_y);
441                    if let Some(rect) = state.plot_rect {
442                        self.apply_manual_view_with_link(&mut plot, &mut state, rect, next);
443                    }
444                }
445            }
446        }
447
448        cx.notify();
449    }
450}
451
452impl Render for PlotView {
453    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
454        let plot = Arc::clone(&self.plot);
455        let state = Arc::clone(&self.state);
456        let config = self.config.clone();
457        let link = self.link.clone();
458        let base_theme = plot.read().expect("plot lock").theme().clone();
459        #[cfg(feature = "gpui_component_theme")]
460        let theme = resolve_theme(base_theme, cx);
461        #[cfg(not(feature = "gpui_component_theme"))]
462        let theme = base_theme;
463        let hover_region_id = Arc::as_ptr(&self.state) as usize;
464
465        div()
466            .id(("gpui-plot-view", hover_region_id))
467            .size_full()
468            .bg(to_hsla(theme.background))
469            .child(
470                canvas(
471                    move |bounds, window, _app| {
472                        let mut plot = plot.write().expect("plot lock");
473                        let mut state = state.write().expect("plot state lock");
474                        #[cfg(feature = "gpui_component_theme")]
475                        if let Some(theme) = resolve_gpui_component_theme(_app) {
476                            plot.set_theme(theme);
477                        }
478                        if let Some(link) = &link {
479                            apply_link_updates(link, &mut plot, &mut state);
480                        }
481                        build_frame(&mut plot, &mut state, &config, bounds, window)
482                    },
483                    move |_, frame, window, cx| {
484                        paint_frame(&frame, window, cx);
485                    },
486                )
487                .size_full(),
488            )
489            .on_mouse_down(
490                MouseButton::Left,
491                cx.listener(|this, ev, _, cx| {
492                    this.on_mouse_down(ev, cx);
493                }),
494            )
495            .on_mouse_down(
496                MouseButton::Right,
497                cx.listener(|this, ev, _, cx| {
498                    this.on_mouse_down(ev, cx);
499                }),
500            )
501            .on_mouse_move(cx.listener(|this, ev, _, cx| {
502                this.on_mouse_move(ev, cx);
503            }))
504            .on_hover(cx.listener(|this, hovered, window, cx| {
505                this.on_hover_state_change(*hovered, window, cx);
506            }))
507            .on_mouse_up(
508                MouseButton::Left,
509                cx.listener(|this, ev, _, cx| {
510                    this.on_mouse_up(ev, cx);
511                }),
512            )
513            .on_mouse_up(
514                MouseButton::Right,
515                cx.listener(|this, ev, _, cx| {
516                    this.on_mouse_up(ev, cx);
517                }),
518            )
519            .on_mouse_up_out(
520                MouseButton::Left,
521                cx.listener(|this, ev, _, cx| {
522                    this.on_mouse_up_out(ev, cx);
523                }),
524            )
525            .on_mouse_up_out(
526                MouseButton::Right,
527                cx.listener(|this, ev, _, cx| {
528                    this.on_mouse_up_out(ev, cx);
529                }),
530            )
531            .on_scroll_wheel(cx.listener(|this, ev, window, cx| {
532                this.on_scroll(ev, window, cx);
533            }))
534    }
535}
536
537#[cfg(feature = "gpui_component_theme")]
538fn resolve_gpui_component_theme(cx: &gpui::App) -> Option<crate::style::Theme> {
539    if cx.has_global::<gpui_component::Theme>() {
540        Some(crate::style::Theme::from_gpui_component_theme(
541            gpui_component::Theme::global(cx),
542        ))
543    } else {
544        None
545    }
546}
547
548#[cfg(feature = "gpui_component_theme")]
549fn resolve_theme(base: crate::style::Theme, cx: &gpui::App) -> crate::style::Theme {
550    resolve_gpui_component_theme(cx).unwrap_or(base)
551}
552
553/// A handle for mutating a [`Plot`] held inside a [`PlotView`].
554///
555/// The handle clones cheaply and can be moved into async tasks.
556#[derive(Debug, Clone)]
557pub struct PlotHandle {
558    plot: Arc<RwLock<Plot>>,
559}
560
561impl PlotHandle {
562    /// Read the plot state.
563    ///
564    /// The plot is locked for the duration of the callback.
565    pub fn read<R>(&self, f: impl FnOnce(&Plot) -> R) -> R {
566        let plot = self.plot.read().expect("plot lock");
567        f(&plot)
568    }
569
570    /// Mutate the plot state.
571    ///
572    /// The plot is locked for the duration of the callback.
573    pub fn write<R>(&self, f: impl FnOnce(&mut Plot) -> R) -> R {
574        let mut plot = self.plot.write().expect("plot lock");
575        f(&mut plot)
576    }
577}
578
579fn apply_link_updates(link: &LinkBinding, plot: &mut Plot, state: &mut PlotUiState) {
580    if let Some(update) = link.group.latest_view_update()
581        && update.seq > state.link_view_seq
582    {
583        state.link_view_seq = update.seq;
584        if update.source != link.member_id {
585            match update.kind {
586                ViewSyncKind::Reset => {
587                    if link.options.link_reset {
588                        plot.reset_view();
589                        state.viewport = None;
590                        state.transform = None;
591                        state.linked_brush_x = None;
592                    }
593                }
594                ViewSyncKind::Manual {
595                    viewport,
596                    sync_x,
597                    sync_y,
598                } => {
599                    let mut next = plot
600                        .viewport()
601                        .or_else(|| plot.data_bounds())
602                        .unwrap_or(viewport);
603                    let mut changed = false;
604                    if sync_x && link.options.link_x {
605                        next.x = viewport.x;
606                        changed = true;
607                    }
608                    if sync_y && link.options.link_y {
609                        next.y = viewport.y;
610                        changed = true;
611                    }
612                    if changed {
613                        plot.set_manual_view(next);
614                        state.viewport = Some(next);
615                        if let Some(rect) = state.plot_rect {
616                            state.transform = Transform::new(next, rect);
617                        }
618                    }
619                }
620            }
621        }
622    }
623
624    if let Some(update) = link.group.latest_cursor_update()
625        && update.seq > state.link_cursor_seq
626    {
627        state.link_cursor_seq = update.seq;
628        if update.source != link.member_id && link.options.link_cursor {
629            state.linked_cursor_x = update.x;
630        }
631    }
632
633    if let Some(update) = link.group.latest_brush_update()
634        && update.seq > state.link_brush_seq
635    {
636        state.link_brush_seq = update.seq;
637        if update.source != link.member_id && link.options.link_brush {
638            state.linked_brush_x = update.x_range;
639            if let Some(x_range) = update.x_range {
640                let y_range = plot
641                    .viewport()
642                    .or_else(|| plot.data_bounds())
643                    .map(|viewport| viewport.y)
644                    .unwrap_or_else(|| Range::new(0.0, 1.0));
645                let next = Viewport::new(x_range, y_range);
646                plot.set_manual_view(next);
647                state.viewport = Some(next);
648                if let Some(rect) = state.plot_rect {
649                    state.transform = Transform::new(next, rect);
650                }
651            }
652        }
653    }
654}
655
656fn screen_point(point: Point<Pixels>) -> ScreenPoint {
657    ScreenPoint::new(f32::from(point.x), f32::from(point.y))
658}
659
660fn apply_manual_view(
661    plot: &mut Plot,
662    state: &mut PlotUiState,
663    rect: ScreenRect,
664    viewport: Viewport,
665) {
666    plot.set_manual_view(viewport);
667    state.viewport = Some(viewport);
668    state.transform = Transform::new(viewport, rect);
669}
670
671fn revert_pin_toggle(plot: &mut Plot, toggle: PinToggle) {
672    let pins = plot.pins_mut();
673    if toggle.added {
674        if let Some(index) = pins.iter().position(|pin| *pin == toggle.pin) {
675            pins.swap_remove(index);
676        }
677    } else if !pins.contains(&toggle.pin) {
678        pins.push(toggle.pin);
679    }
680}
681
682fn is_drag_button_held(mode: DragMode, pressed_button: Option<MouseButton>) -> bool {
683    let expected = match mode {
684        DragMode::ZoomRect => MouseButton::Right,
685        DragMode::Pan | DragMode::ZoomX | DragMode::ZoomY => MouseButton::Left,
686    };
687    pressed_button == Some(expected)
688}
689
690fn scroll_zoom_factor(delta_y: f32) -> f64 {
691    if delta_y.abs() < 0.01 {
692        return 1.0;
693    }
694
695    (1.0 - (delta_y as f64 * 0.002)).clamp(0.1, 10.0)
696}
697
698trait ViewportCenter {
699    fn center(&self) -> DataPoint;
700    fn x_center(&self) -> DataPoint;
701    fn y_center(&self) -> DataPoint;
702}
703
704impl ViewportCenter for Viewport {
705    fn center(&self) -> DataPoint {
706        DataPoint::new(
707            (self.x.min + self.x.max) * 0.5,
708            (self.y.min + self.y.max) * 0.5,
709        )
710    }
711
712    fn x_center(&self) -> DataPoint {
713        DataPoint::new(
714            (self.x.min + self.x.max) * 0.5,
715            (self.y.min + self.y.max) * 0.5,
716        )
717    }
718
719    fn y_center(&self) -> DataPoint {
720        DataPoint::new(
721            (self.x.min + self.x.max) * 0.5,
722            (self.y.min + self.y.max) * 0.5,
723        )
724    }
725}
726
727#[cfg(test)]
728mod tests {
729    use super::{DragMode, MouseButton, is_drag_button_held, scroll_zoom_factor};
730
731    #[test]
732    fn drag_requires_matching_button() {
733        assert!(is_drag_button_held(DragMode::Pan, Some(MouseButton::Left)));
734        assert!(is_drag_button_held(
735            DragMode::ZoomX,
736            Some(MouseButton::Left)
737        ));
738        assert!(is_drag_button_held(
739            DragMode::ZoomY,
740            Some(MouseButton::Left)
741        ));
742        assert!(is_drag_button_held(
743            DragMode::ZoomRect,
744            Some(MouseButton::Right)
745        ));
746        assert!(!is_drag_button_held(
747            DragMode::Pan,
748            Some(MouseButton::Right)
749        ));
750        assert!(!is_drag_button_held(DragMode::ZoomRect, None));
751    }
752
753    #[test]
754    fn positive_scroll_delta_zooms_in_after_reversal() {
755        let factor = scroll_zoom_factor(120.0);
756        assert!(factor < 1.0, "expected zoom-in factor, got {factor}");
757    }
758
759    #[test]
760    fn negative_scroll_delta_zooms_out_after_reversal() {
761        let factor = scroll_zoom_factor(-120.0);
762        assert!(factor > 1.0, "expected zoom-out factor, got {factor}");
763    }
764}