Skip to main content

model/
drag.rs

1//! Drag policy — `Draggable`, position adjustment for selected elements.
2//!
3//! `Draggable` extends [`Selectable`]: only objects
4//! the user can select can be dragged. Implementations provide a single
5//! mapping — which nudge-path [`Element`] they move — and nothing else. The
6//! default [`Draggable::drag_by`] routes every position change through
7//! [`Config::nudge`], so a drag can never bypass that path: the same
8//! feasibility check (`NudgeReject::OutOfBounds` keeps elements inside the
9//! chart area) and the same rotated-frame offset mapping apply whether the
10//! move came from a drag, a keyboard nudge, or programmatic adjustment.
11//!
12//! Axes are draggable too, with their own rule inside the same path:
13//! [`Element::Axis`] *detaches* the axis — it moves only perpendicular to its
14//! direction (y-axes horizontally, x-axes vertically) via `line_offset`,
15//! while the data area, grid, and data transform stay put. Tick positions
16//! along the axis therefore stay aligned with the data. The data area itself
17//! is draggable as a whole ([`Element::DataArea`]): it translates without
18//! resizing by shifting opposite margins in tandem, and it is also resizable
19//! via its handles ([`crate::resize::Resizable`]).
20
21use crate::config::Config;
22use crate::layout::{Element, NudgeResult};
23use crate::select::{
24    AxisElement, AxisLabelElement, AxisTitleElement, ChartTitleElement, ColorBarAxisElement,
25    ColorBarElement, ColorBarLabelElement, ColorBarTitleElement, DataAreaElement, LegendElement,
26    Selectable,
27};
28
29/// State that owns position changes. Existing Cartesian callers use `Config`;
30/// bounded chart decorations use their own SSOT without duplicating hit/drag policy.
31pub trait DragConfig {
32    type Element;
33    fn nudge(&mut self, element: Self::Element, dx: f32, dy: f32) -> NudgeResult;
34}
35impl DragConfig for Config {
36    type Element = Element;
37    fn nudge(&mut self, element: Element, dx: f32, dy: f32) -> NudgeResult {
38        Config::nudge(self, element, dx, dy)
39    }
40}
41
42/// A selectable object whose position can be adjusted by drag & drop.
43pub trait Draggable<C: DragConfig = Config>: Selectable<C> {
44    /// The nudge-path element this drag target moves. This mapping is the
45    /// only thing an implementation defines; movement itself happens in
46    /// [`Self::drag_by`].
47    fn nudge_element(&self) -> C::Element;
48
49    /// Default drag pipeline: apply the pointer delta through
50    /// [`Config::nudge`]. Returns [`NudgeResult::Rejected`] (and leaves `cfg`
51    /// untouched) when the move would leave the chart area.
52    ///
53    /// Call this per pointer-move with the frame delta; the accumulated
54    /// offset lands in the element's `offset_x` / `offset_y` config fields,
55    /// so a drop needs no extra commit step.
56    fn drag_by(&self, cfg: &mut C, dx: f32, dy: f32) -> NudgeResult {
57        cfg.nudge(self.nudge_element(), dx, dy)
58    }
59}
60
61impl Draggable for ChartTitleElement {
62    fn nudge_element(&self) -> Element {
63        Element::ChartTitle
64    }
65}
66
67impl Draggable for AxisTitleElement {
68    fn nudge_element(&self) -> Element {
69        Element::AxisTitle(self.side.clone())
70    }
71}
72
73impl Draggable for AxisLabelElement {
74    fn nudge_element(&self) -> Element {
75        Element::AxisLabel(self.side.clone())
76    }
77}
78
79impl Draggable for AxisElement {
80    fn nudge_element(&self) -> Element {
81        Element::Axis(self.side.clone())
82    }
83}
84
85impl Draggable for LegendElement {
86    fn nudge_element(&self) -> Element {
87        Element::Legend
88    }
89}
90
91impl Draggable for DataAreaElement {
92    fn nudge_element(&self) -> Element {
93        Element::DataArea
94    }
95}
96
97impl Draggable for ColorBarElement {
98    fn nudge_element(&self) -> Element {
99        Element::ColorBar
100    }
101}
102
103impl Draggable for ColorBarAxisElement {
104    fn nudge_element(&self) -> Element {
105        Element::ColorBarAxis
106    }
107}
108
109impl Draggable for ColorBarLabelElement {
110    fn nudge_element(&self) -> Element {
111        Element::ColorBarLabel
112    }
113}
114
115impl Draggable for ColorBarTitleElement {
116    fn nudge_element(&self) -> Element {
117        Element::ColorBarTitle
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::default::default_config;
125    use crate::layout::{ChartArea, NudgeReject, Rect, Side};
126    use crate::text::rich_segments_from_text;
127
128    fn cfg_800x600() -> Config {
129        let mut cfg = default_config();
130        cfg.chart_area = ChartArea(Rect {
131            x: 0,
132            y: 0,
133            width: 800,
134            height: 600,
135        });
136        cfg.chart_title.text.segments = rich_segments_from_text("Title");
137        cfg
138    }
139
140    #[test]
141    fn drag_moves_chart_title_offset() {
142        let mut cfg = cfg_800x600();
143        let r = ChartTitleElement.drag_by(&mut cfg, 5.0, 3.0);
144        assert_eq!(r, NudgeResult::Moved);
145        assert_eq!(cfg.chart_title.offset_x, 5.0);
146        assert_eq!(cfg.chart_title.offset_y, 3.0);
147    }
148
149    #[test]
150    fn drag_matches_direct_nudge_path() {
151        let mut via_drag = cfg_800x600();
152        let mut via_nudge = cfg_800x600();
153        let el = AxisTitleElement { side: Side::Left };
154        el.drag_by(&mut via_drag, 4.0, -2.0);
155        via_nudge.nudge(Element::AxisTitle(Side::Left), 4.0, -2.0);
156        assert_eq!(via_drag, via_nudge);
157    }
158
159    #[test]
160    fn rejected_drag_leaves_config_unchanged() {
161        let mut cfg = cfg_800x600();
162        let before = cfg.clone();
163        let r = ChartTitleElement.drag_by(&mut cfg, -1e6, 0.0);
164        assert_eq!(r, NudgeResult::Rejected(NudgeReject::OutOfBounds));
165        assert_eq!(cfg, before);
166    }
167
168    #[test]
169    fn left_axis_title_drag_respects_rotated_frame() {
170        // Screen-space drag (dx, dy) on the rotated left title must land as
171        // local (-dy, dx) — same as the nudge path's inverse rotation.
172        let mut cfg = cfg_800x600();
173        cfg.left_y.title_option.text.segments = rich_segments_from_text("Y");
174        let el = AxisTitleElement { side: Side::Left };
175        let r = el.drag_by(&mut cfg, 3.0, 7.0);
176        assert_eq!(r, NudgeResult::Moved);
177        assert_eq!(cfg.left_y.title_option.offset_x, -7.0);
178        assert_eq!(cfg.left_y.title_option.offset_y, 3.0);
179    }
180
181    // Dragging a y-axis applies only the x component (perpendicular rule)
182    // and detaches the axis: line_offset moves, the data area does not — so
183    // tick positions along the axis stay aligned with the data.
184    #[test]
185    fn y_axis_drag_detaches_horizontally_only() {
186        let mut cfg = cfg_800x600();
187        let da_before = cfg.data_area().unwrap();
188
189        let el = AxisElement { side: Side::Left };
190        let r = el.drag_by(&mut cfg, 12.0, -40.0);
191        assert_eq!(r, NudgeResult::Moved);
192        assert_eq!(cfg.left_y.line_offset, 12.0);
193        assert_eq!(cfg.data_area().unwrap(), da_before);
194    }
195
196    #[test]
197    fn x_axis_drag_detaches_vertically_only() {
198        let mut cfg = cfg_800x600();
199        let da_before = cfg.data_area().unwrap();
200        let el = AxisElement { side: Side::Bottom };
201        let r = el.drag_by(&mut cfg, -40.0, -6.0);
202        assert_eq!(r, NudgeResult::Moved);
203        assert_eq!(cfg.bottom_x.line_offset, -6.0);
204        assert_eq!(cfg.data_area().unwrap(), da_before);
205    }
206
207    #[test]
208    fn axis_label_drag_moves_label_offsets() {
209        let mut cfg = cfg_800x600();
210        let el = AxisLabelElement { side: Side::Bottom };
211        let r = el.drag_by(&mut cfg, 2.0, 5.0);
212        assert_eq!(r, NudgeResult::Moved);
213        assert_eq!(cfg.bottom_x.label_style.label_offset_x, 2.0);
214        assert_eq!(cfg.bottom_x.label_style.label_offset_y, 5.0);
215    }
216
217    // The hit-map round trip hosts use: hit → as_draggable → drag_by.
218    #[test]
219    fn as_draggable_exposes_drag_through_hitmap() {
220        use crate::select::{HitMap, Selectable};
221        use crate::text::{MeasureText, RichText, TextExtents};
222
223        struct FixedMeasure;
224        impl MeasureText for FixedMeasure {
225            fn measure_rich(&self, rt: &RichText) -> TextExtents {
226                TextExtents {
227                    width: rt.segments.len() as f32 * 8.0,
228                    ascent: 10.0,
229                    descent: 3.0,
230                }
231            }
232        }
233
234        let mut cfg = cfg_800x600();
235        let map = HitMap::standard_chart();
236
237        // Hit the chart title and drag it via the trait-object route.
238        let tb = ChartTitleElement.bounds(&cfg, &FixedMeasure).unwrap();
239        let id = map
240            .hit_test(&cfg, &FixedMeasure, tb.x + 1.0, tb.y + 1.0)
241            .unwrap();
242        let drag = map
243            .get(id)
244            .unwrap()
245            .as_draggable()
246            .expect("title is draggable");
247        assert_eq!(drag.drag_by(&mut cfg, 4.0, 2.0), NudgeResult::Moved);
248        assert_eq!(cfg.chart_title.offset_x, 4.0);
249
250        // The data area is draggable too (whole-area translation).
251        let db = crate::select::DataAreaElement
252            .bounds(&cfg, &FixedMeasure)
253            .unwrap();
254        let id2 = map
255            .hit_test(
256                &cfg,
257                &FixedMeasure,
258                db.x + db.width * 0.5,
259                db.y + db.height * 0.5,
260            )
261            .unwrap();
262        assert!(map.get(id2).unwrap().as_draggable().is_some());
263    }
264
265    // Dragging the data area translates it without resizing: opposite margins
266    // shift in tandem, size is preserved, and ticks/data follow by derivation.
267    // Deltas stay inside the default top/right margins (8 px).
268    #[test]
269    fn data_area_drag_translates_without_resize() {
270        let mut cfg = cfg_800x600();
271        let da_before = cfg.data_area().unwrap();
272
273        let r = DataAreaElement.drag_by(&mut cfg, 5.0, 3.0);
274        assert_eq!(r, NudgeResult::Moved);
275        let da_after = cfg.data_area().unwrap();
276        assert_eq!(da_after.x, da_before.x + 5);
277        assert_eq!(da_after.y, da_before.y + 3);
278        assert_eq!(da_after.width, da_before.width);
279        assert_eq!(da_after.height, da_before.height);
280    }
281
282    // A blocked axis slides: the invalid horizontal component clamps while
283    // the valid vertical one still applies.
284    #[test]
285    fn data_area_drag_clamps_per_axis() {
286        let mut cfg = cfg_800x600();
287        let da_before = cfg.data_area().unwrap();
288        let right_margin = cfg.right_y.out_margin; // default 8 — +50 px overflows
289
290        let r = DataAreaElement.drag_by(&mut cfg, right_margin + 50.0, 3.0);
291        assert_eq!(r, NudgeResult::Moved);
292        let da_after = cfg.data_area().unwrap();
293        assert_eq!(
294            da_after.x, da_before.x,
295            "blocked horizontal component must not apply"
296        );
297        assert_eq!(da_after.y, da_before.y + 3);
298    }
299
300    #[test]
301    fn data_area_drag_rejected_when_fully_blocked() {
302        let mut cfg = cfg_800x600();
303        let before = cfg.clone();
304        let r = DataAreaElement.drag_by(&mut cfg, 1e6, -1e6);
305        assert_eq!(
306            r,
307            NudgeResult::Rejected(crate::layout::NudgeReject::OutOfBounds)
308        );
309        assert_eq!(cfg, before);
310    }
311
312    /// The bar drags like the legend: the delta lands in its own offsets, and
313    /// the *band* does not move — so the data area does not reflow out from
314    /// under the pointer mid-drag.
315    #[test]
316    fn dragging_the_colorbar_moves_its_offsets_and_not_the_layout() {
317        use crate::layout::Side;
318        use crate::select::ColorBarElement;
319
320        let mut cfg = cfg_800x600();
321        cfg.colorbar = Some(crate::default::default_colorbar_options());
322        let margins_before = cfg.margins();
323        let area_before = cfg.data_area().expect("data area");
324
325        assert_eq!(
326            ColorBarElement.drag_by(&mut cfg, 7.0, -3.0),
327            NudgeResult::Moved
328        );
329        let bar = cfg.colorbar.as_ref().expect("colourbar");
330        assert_eq!((bar.offset_x, bar.offset_y), (7.0, -3.0));
331        assert_eq!(cfg.margins(), margins_before);
332        assert_eq!(cfg.data_area().expect("data area"), area_before);
333
334        // Deltas accumulate, and the drawn rect follows them.
335        let da = cfg.data_area().expect("data area");
336        let anchored = {
337            let mut probe = cfg.clone();
338            let bar = probe.colorbar.as_mut().expect("colourbar");
339            bar.offset_x = 0.0;
340            bar.offset_y = 0.0;
341            crate::layout::colorbar_rect(
342                &probe.chart_area,
343                &da,
344                probe.chart_title.top_margin,
345                probe.colorbar.as_ref().expect("colourbar"),
346            )
347        };
348        ColorBarElement.drag_by(&mut cfg, 1.0, 1.0);
349        let bar = cfg.colorbar.as_ref().expect("colourbar");
350        assert_eq!((bar.offset_x, bar.offset_y), (8.0, -2.0));
351        let moved =
352            crate::layout::colorbar_rect(&cfg.chart_area, &da, cfg.chart_title.top_margin, bar);
353        assert_eq!(moved.x, anchored.x + 8.0);
354        assert_eq!(moved.y, anchored.y - 2.0);
355
356        // A drag that would leave the chart area is rejected, state untouched.
357        let before = cfg.clone();
358        assert_eq!(
359            ColorBarElement.drag_by(&mut cfg, 10_000.0, 0.0),
360            NudgeResult::Rejected(NudgeReject::OutOfBounds)
361        );
362        assert_eq!(cfg, before);
363
364        // With no colourbar there is nothing to drag.
365        let mut bare = cfg_800x600();
366        bare.colorbar = None;
367        assert_eq!(
368            ColorBarElement.drag_by(&mut bare, 1.0, 1.0),
369            NudgeResult::Rejected(NudgeReject::OutOfBounds)
370        );
371        let _ = Side::Right;
372    }
373
374    #[test]
375    fn colorbar_detail_drags_update_only_the_z_axis_fields() {
376        let mut cfg = cfg_800x600();
377        let mut bar = crate::default::default_colorbar_options();
378        bar.side = Side::Right;
379        bar.axis.title_option.visible = true;
380        bar.axis.title_option.text =
381            crate::text::RichText::plain("z", crate::color::Color::BLACK, 12.0, "");
382        cfg.colorbar = Some(bar);
383        let area_before = cfg.data_area().expect("data area");
384        let strip_offsets_before = {
385            let bar = cfg.colorbar.as_ref().unwrap();
386            (bar.offset_x, bar.offset_y)
387        };
388
389        assert_eq!(
390            ColorBarAxisElement.drag_by(&mut cfg, 6.0, 99.0),
391            NudgeResult::Moved
392        );
393        assert_eq!(cfg.colorbar.as_ref().unwrap().axis.line_offset, 6.0);
394
395        assert_eq!(
396            ColorBarLabelElement.drag_by(&mut cfg, 4.0, -2.0),
397            NudgeResult::Moved
398        );
399        let labels = &cfg.colorbar.as_ref().unwrap().axis.label_style;
400        assert_eq!((labels.label_offset_x, labels.label_offset_y), (4.0, -2.0));
401
402        assert_eq!(
403            ColorBarTitleElement.drag_by(&mut cfg, 3.0, 7.0),
404            NudgeResult::Moved
405        );
406        let title = &cfg.colorbar.as_ref().unwrap().axis.title_option;
407        // Right-side text is rotated +90°: screen (dx,dy) → local (dy,-dx).
408        assert_eq!((title.offset_x, title.offset_y), (7.0, -3.0));
409
410        let bar = cfg.colorbar.as_ref().unwrap();
411        assert_eq!((bar.offset_x, bar.offset_y), strip_offsets_before);
412        assert_eq!(cfg.data_area().expect("data area"), area_before);
413    }
414}