Skip to main content

gpui_liveplot/
interaction.rs

1//! Interaction helpers for panning, zooming, and pin selection.
2//!
3//! These helpers are used by render backends to implement consistent
4//! interaction semantics across platforms.
5
6use crate::geom::{Point, ScreenPoint, ScreenRect};
7use crate::series::SeriesId;
8use crate::transform::Transform;
9use crate::view::{Range, Viewport};
10
11/// Interaction hit regions.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub(crate) enum HitRegion {
14    /// Plot data area.
15    Plot,
16    /// X axis area.
17    XAxis,
18    /// Y axis area.
19    YAxis,
20    /// Outside of the plot.
21    Outside,
22}
23
24/// Screen regions for hit testing.
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub(crate) struct PlotRegions {
27    /// Plot data area.
28    pub(crate) plot: ScreenRect,
29    /// X axis area.
30    pub(crate) x_axis: ScreenRect,
31    /// Y axis area.
32    pub(crate) y_axis: ScreenRect,
33}
34
35impl PlotRegions {
36    /// Determine which region contains the point.
37    pub(crate) fn hit_test(&self, point: ScreenPoint) -> HitRegion {
38        if contains(self.plot, point) {
39            HitRegion::Plot
40        } else if contains(self.x_axis, point) {
41            HitRegion::XAxis
42        } else if contains(self.y_axis, point) {
43            HitRegion::YAxis
44        } else {
45            HitRegion::Outside
46        }
47    }
48}
49
50/// Pin binding to a stable point identity.
51///
52/// Pins are stable references to a specific series and point index, allowing
53/// annotations to remain consistent even when the view is decimated.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct Pin {
56    /// Series identifier.
57    pub series_id: SeriesId,
58    /// Point index within the series.
59    pub point_index: usize,
60}
61
62/// Toggle a pin in the list. Returns true if added, false if removed.
63pub(crate) fn toggle_pin(pins: &mut Vec<Pin>, pin: Pin) -> bool {
64    if let Some(index) = pins.iter().position(|existing| *existing == pin) {
65        pins.swap_remove(index);
66        false
67    } else {
68        pins.push(pin);
69        true
70    }
71}
72
73/// Pan a viewport by a pixel delta.
74pub(crate) fn pan_viewport(
75    viewport: Viewport,
76    delta_pixels: ScreenPoint,
77    transform: &Transform,
78) -> Option<Viewport> {
79    let origin = transform.screen_to_data(ScreenPoint::new(0.0, 0.0))?;
80    let shifted = transform.screen_to_data(ScreenPoint::new(delta_pixels.x, delta_pixels.y))?;
81    let dx = shifted.x - origin.x;
82    let dy = shifted.y - origin.y;
83    Some(Viewport::new(
84        Range::new(viewport.x.min - dx, viewport.x.max - dx),
85        Range::new(viewport.y.min - dy, viewport.y.max - dy),
86    ))
87}
88
89/// Zoom a viewport around a center point.
90pub(crate) fn zoom_viewport(
91    viewport: Viewport,
92    center: Point,
93    factor_x: f64,
94    factor_y: f64,
95) -> Viewport {
96    let x_min = center.x + (viewport.x.min - center.x) * factor_x;
97    let x_max = center.x + (viewport.x.max - center.x) * factor_x;
98    let y_min = center.y + (viewport.y.min - center.y) * factor_y;
99    let y_max = center.y + (viewport.y.max - center.y) * factor_y;
100    Viewport::new(Range::new(x_min, x_max), Range::new(y_min, y_max))
101}
102
103/// Convert a zoom rectangle into a new viewport.
104pub(crate) fn zoom_to_rect(
105    viewport: Viewport,
106    rect: ScreenRect,
107    transform: &Transform,
108) -> Option<Viewport> {
109    if rect.width().abs() < 2.0 || rect.height().abs() < 2.0 {
110        return Some(viewport);
111    }
112    let data_min = transform.screen_to_data(rect.min)?;
113    let data_max = transform.screen_to_data(rect.max)?;
114    Some(Viewport::new(
115        Range::new(data_min.x, data_max.x),
116        Range::new(data_min.y, data_max.y),
117    ))
118}
119
120/// Compute a zoom factor from a drag delta and axis length.
121pub(crate) fn zoom_factor_from_drag(delta_pixels: f32, axis_pixels: f32) -> f64 {
122    if axis_pixels <= 0.0 {
123        return 1.0;
124    }
125    let normalized = delta_pixels as f64 / axis_pixels as f64;
126    (1.0 - normalized).clamp(0.1, 10.0)
127}
128
129fn contains(rect: ScreenRect, point: ScreenPoint) -> bool {
130    point.x >= rect.min.x && point.x <= rect.max.x && point.y >= rect.min.y && point.y <= rect.max.y
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn hit_test_regions() {
139        let regions = PlotRegions {
140            plot: ScreenRect::new(ScreenPoint::new(0.0, 0.0), ScreenPoint::new(10.0, 10.0)),
141            x_axis: ScreenRect::new(ScreenPoint::new(0.0, 10.0), ScreenPoint::new(10.0, 12.0)),
142            y_axis: ScreenRect::new(ScreenPoint::new(-2.0, 0.0), ScreenPoint::new(0.0, 10.0)),
143        };
144        assert_eq!(
145            regions.hit_test(ScreenPoint::new(5.0, 5.0)),
146            HitRegion::Plot
147        );
148        assert_eq!(
149            regions.hit_test(ScreenPoint::new(5.0, 11.0)),
150            HitRegion::XAxis
151        );
152        assert_eq!(
153            regions.hit_test(ScreenPoint::new(-1.0, 5.0)),
154            HitRegion::YAxis
155        );
156    }
157}