Skip to main content

model/layout/
nudge.rs

1use crate::text::TextExtents;
2
3use super::{
4    Config, LABEL_GAP, Side, TitleBand, axis_mut, axis_offset, axis_ref,
5    axis_title_offset_to_screen, axis_title_placement, chart_title_placement, colorbar_rect,
6    colorbar_title_placement, legend_rect, point_on_rect_side, screen_offset_to_axis_title,
7};
8
9// Nudge types.
10
11#[derive(Debug, Clone, PartialEq)]
12pub enum Element {
13    ChartTitle,
14    AxisTitle(Side),
15    AxisLabel(Side),
16    /// The axis line itself — a *detached* move. Shifts only perpendicular to
17    /// its direction (y-axes horizontally, x-axes vertically) via
18    /// `AxisOptions::line_offset`; the data area, grid, and data transform
19    /// stay put, so tick positions along the axis stay aligned with the data.
20    Axis(Side),
21    /// One edge of the data area — moves by adjusting that side's
22    /// `out_margin`, dragging the whole data area boundary with it. Used by
23    /// the data-area resize handles.
24    DataAreaEdge(Side),
25    /// The whole data area — translates without resizing by shifting opposite
26    /// margins in tandem (left grows as right shrinks, etc.). Ticks, grid,
27    /// and the data transform all derive from the data area, so everything
28    /// moves as one.
29    DataArea,
30    /// The legend box — moves freely via `legend.offset_{x,y}` relative to
31    /// its corner anchor.
32    Legend,
33    /// The colourbar strip — moves freely via `colorbar.offset_{x,y}` relative
34    /// to its `side` + `align` anchor, the same rule as [`Self::Legend`].
35    ColorBar,
36    /// The colourbar axis line and ticks. Like a chart axis, a drag detaches it
37    /// only perpendicular to its direction through `colorbar.axis.line_offset`.
38    ColorBarAxis,
39    /// The colourbar tick-label band. A drag updates the z axis' label offsets.
40    ColorBarLabel,
41    /// The colourbar title. A drag updates the z axis' title offsets in the
42    /// title's rotated local frame.
43    ColorBarTitle,
44    /// One edge of the colourbar strip, named by the **screen** side of the
45    /// strip it is on — the resize-handle target, mirroring
46    /// [`Self::DataAreaEdge`].
47    ///
48    /// Which dimension it changes follows the bar's orientation, not the
49    /// element: on a vertical bar the left/right edges are `thickness_px` and
50    /// the top/bottom edges are `length_frac`; on a horizontal bar it is the
51    /// other way round. The element stays orientation-free because the handle
52    /// that produced it only knows screen directions.
53    ColorBarEdge(Side),
54}
55
56#[derive(Debug, Clone, PartialEq)]
57pub enum NudgeResult {
58    Moved,
59    Rejected(NudgeReject),
60}
61
62#[derive(Debug, Clone, PartialEq)]
63pub enum NudgeReject {
64    OutOfBounds,
65}
66
67// Per-element anchor and current offset.
68
69/// Measurement-free label extent estimate used only for nudge containment.
70const LABEL_EXTENT_EST: f32 = 10.0;
71const ZERO_EXTENTS: TextExtents = TextExtents {
72    width: 0.0,
73    ascent: 0.0,
74    descent: 0.0,
75};
76
77/// Measurement-free representative screen anchor used by nudge containment.
78/// Exact measured bounds remain the `Selectable` policy.
79fn element_anchor(cfg: &Config, element: &Element) -> Option<(f32, f32)> {
80    let ca = &cfg.chart_area;
81    let da = cfg.data_area().ok()?;
82
83    let anchor = match element {
84        Element::ChartTitle => {
85            chart_title_placement(ca, cfg.chart_title.top_margin, (0.0, 0.0), ZERO_EXTENTS).origin
86        }
87        Element::AxisTitle(side) => {
88            let axis = axis_ref(cfg, side);
89            axis_title_placement(
90                side.clone(),
91                ca,
92                &da,
93                TitleBand {
94                    out_margin: axis.out_margin,
95                    chart_title_margin: cfg.chart_title.top_margin,
96                    edge_inset: cfg.colorbar_band(side),
97                },
98                (0.0, 0.0),
99                ZERO_EXTENTS,
100            )
101            .origin
102        }
103        // Axis label: just outside the tick end (with approximate label extent).
104        Element::AxisLabel(Side::Top) => (
105            da.x as f32 + da.width as f32 * 0.5,
106            da.y as f32 - cfg.top_x.major_tick_length - LABEL_GAP - LABEL_EXTENT_EST,
107        ),
108        Element::AxisLabel(Side::Bottom) => (
109            da.x as f32 + da.width as f32 * 0.5,
110            (da.y + da.height) as f32
111                + cfg.bottom_x.major_tick_length
112                + LABEL_GAP
113                + LABEL_EXTENT_EST,
114        ),
115        Element::AxisLabel(Side::Left) => (
116            da.x as f32 - cfg.left_y.major_tick_length - LABEL_GAP - LABEL_EXTENT_EST,
117            da.y as f32 + da.height as f32 * 0.5,
118        ),
119        Element::AxisLabel(Side::Right) => (
120            (da.x + da.width) as f32 + cfg.right_y.major_tick_length + LABEL_GAP + LABEL_EXTENT_EST,
121            da.y as f32 + da.height as f32 * 0.5,
122        ),
123        // Legend: corner anchor point (inset corner of the data area). The
124        // box extent isn't known here (it needs text measurement), but the
125        // chart_area containment check only needs a representative point.
126        Element::Legend => {
127            let rect = legend_rect(&da, cfg.legend.corner, 0.0, (0.0, 0.0), ZERO_EXTENTS);
128            (rect.x, rect.y)
129        }
130        // Colourbar: the un-offset strip corner. Same shape as the legend —
131        // a representative point is all the chart_area containment check needs.
132        Element::ColorBar => {
133            let bar = cfg.colorbar.as_ref()?;
134            let mut anchored = bar.clone();
135            anchored.offset_x = 0.0;
136            anchored.offset_y = 0.0;
137            let rect = colorbar_rect(ca, &da, cfg.chart_title.top_margin, &anchored);
138            (rect.x, rect.y)
139        }
140        Element::ColorBarLabel => {
141            let bar = cfg.colorbar.as_ref()?;
142            let rect = colorbar_rect(ca, &da, cfg.chart_title.top_margin, bar);
143            let pos = point_on_rect_side(0.5, &bar.side, &rect);
144            let (axis_dx, axis_dy) = axis_offset(bar.side.clone(), bar.axis.line_offset);
145            let reach = bar.axis.major_tick_length + LABEL_GAP + LABEL_EXTENT_EST;
146            let (out_x, out_y) = match bar.side {
147                Side::Top => (0.0, -reach),
148                Side::Bottom => (0.0, reach),
149                Side::Left => (-reach, 0.0),
150                Side::Right => (reach, 0.0),
151            };
152            (pos.0 + axis_dx + out_x, pos.1 + axis_dy + out_y)
153        }
154        Element::ColorBarTitle => {
155            let bar = cfg.colorbar.as_ref()?;
156            let rect = colorbar_rect(ca, &da, cfg.chart_title.top_margin, bar);
157            colorbar_title_placement(bar.side.clone(), &rect, &bar.axis, (0.0, 0.0), ZERO_EXTENTS)
158                .origin
159        }
160        // Axis / data-area / colourbar-edge moves have their own rules —
161        // dispatched before this function is reached.
162        Element::Axis(_)
163        | Element::ColorBarAxis
164        | Element::DataAreaEdge(_)
165        | Element::DataArea
166        | Element::ColorBarEdge(_) => return None,
167    };
168
169    Some(anchor)
170}
171
172fn current_offset(cfg: &Config, element: &Element) -> (f32, f32) {
173    match element {
174        Element::ChartTitle => (cfg.chart_title.offset_x, cfg.chart_title.offset_y),
175        Element::AxisTitle(side) => {
176            let a = axis_ref(cfg, side);
177            (a.title_option.offset_x, a.title_option.offset_y)
178        }
179        Element::AxisLabel(side) => {
180            let a = axis_ref(cfg, side);
181            (a.label_style.label_offset_x, a.label_style.label_offset_y)
182        }
183        Element::Legend => (cfg.legend.offset_x, cfg.legend.offset_y),
184        Element::ColorBar => cfg
185            .colorbar
186            .as_ref()
187            .map_or((0.0, 0.0), |bar| (bar.offset_x, bar.offset_y)),
188        Element::ColorBarLabel => cfg.colorbar.as_ref().map_or((0.0, 0.0), |bar| {
189            (
190                bar.axis.label_style.label_offset_x,
191                bar.axis.label_style.label_offset_y,
192            )
193        }),
194        Element::ColorBarTitle => cfg.colorbar.as_ref().map_or((0.0, 0.0), |bar| {
195            (
196                bar.axis.title_option.offset_x,
197                bar.axis.title_option.offset_y,
198            )
199        }),
200        // Dispatched before the offset path.
201        Element::Axis(_)
202        | Element::ColorBarAxis
203        | Element::DataAreaEdge(_)
204        | Element::DataArea
205        | Element::ColorBarEdge(_) => (0.0, 0.0),
206    }
207}
208
209// Nudge methods.
210
211impl Config {
212    /// Apply a measurement-free approximate containment policy. Text-bearing
213    /// elements use representative anchors here; exact glyph bounds are kept
214    /// in the `Selectable` path and no text measurer is required by nudge.
215    pub fn nudge(&mut self, element: Element, dx: f32, dy: f32) -> NudgeResult {
216        // Axis / data-area-edge moves have their own rules (perpendicular
217        // only); everything else moves via stored offsets below.
218        if let Element::Axis(side) = element {
219            return self.nudge_axis(side, dx, dy);
220        }
221        if let Element::ColorBarAxis = element {
222            return self.nudge_colorbar_axis(dx, dy);
223        }
224        if let Element::DataAreaEdge(side) = element {
225            return self.nudge_data_area_edge(side, dx, dy);
226        }
227        if let Element::DataArea = element {
228            return self.nudge_data_area(dx, dy);
229        }
230        if let Element::ColorBarEdge(side) = element {
231            return self.nudge_colorbar_edge(side, dx, dy);
232        }
233        let anchor = match element_anchor(self, &element) {
234            Some(a) => a,
235            None => return NudgeResult::Rejected(NudgeReject::OutOfBounds),
236        };
237        // Compute the current screen position by converting the stored local
238        // offset to screen frame (identity except for rotated axis titles).
239        let (ox, oy) = current_offset(self, &element);
240        let (screen_ox, screen_oy) = match &element {
241            Element::AxisTitle(side) => axis_title_offset_to_screen(side.clone(), (ox, oy)),
242            Element::ColorBarTitle => {
243                let Some(side) = self.colorbar.as_ref().map(|bar| bar.side.clone()) else {
244                    return NudgeResult::Rejected(NudgeReject::OutOfBounds);
245                };
246                axis_title_offset_to_screen(side, (ox, oy))
247            }
248            _ => (ox, oy),
249        };
250        let new_x = anchor.0 + screen_ox + dx;
251        let new_y = anchor.1 + screen_oy + dy;
252
253        let ca = &self.chart_area;
254        let x_min = ca.x as f32;
255        let y_min = ca.y as f32;
256        let x_max = (ca.x + ca.width) as f32;
257        let y_max = (ca.y + ca.height) as f32;
258
259        if new_x < x_min || new_x > x_max || new_y < y_min || new_y > y_max {
260            return NudgeResult::Rejected(NudgeReject::OutOfBounds);
261        }
262
263        match element {
264            Element::ChartTitle => {
265                self.chart_title.offset_x += dx;
266                self.chart_title.offset_y += dy;
267            }
268            Element::AxisTitle(side) => {
269                // Left/Right axis titles are drawn with a canvas rotate, so we
270                // map screen dx/dy back into the rotated local frame so the
271                // title moves in the direction the user expects on screen.
272                let (local_dx, local_dy) = screen_offset_to_axis_title(side.clone(), (dx, dy));
273                let a = axis_mut(self, &side);
274                a.title_option.offset_x += local_dx;
275                a.title_option.offset_y += local_dy;
276            }
277            Element::AxisLabel(side) => {
278                // label_offset_{x,y} is a margin-noncontributing visual offset
279                // in screen coordinates; add directly.
280                let a = axis_mut(self, &side);
281                a.label_style.label_offset_x += dx;
282                a.label_style.label_offset_y += dy;
283            }
284            Element::Legend => {
285                self.legend.offset_x += dx;
286                self.legend.offset_y += dy;
287            }
288            Element::ColorBar => {
289                // `element_anchor` already returned `None` for a missing bar, so
290                // reaching here means there is one.
291                if let Some(bar) = self.colorbar.as_mut() {
292                    bar.offset_x += dx;
293                    bar.offset_y += dy;
294                }
295            }
296            Element::ColorBarLabel => {
297                if let Some(bar) = self.colorbar.as_mut() {
298                    bar.axis.label_style.label_offset_x += dx;
299                    bar.axis.label_style.label_offset_y += dy;
300                }
301            }
302            Element::ColorBarTitle => {
303                let Some(side) = self.colorbar.as_ref().map(|bar| bar.side.clone()) else {
304                    return NudgeResult::Rejected(NudgeReject::OutOfBounds);
305                };
306                let (local_dx, local_dy) = screen_offset_to_axis_title(side, (dx, dy));
307                if let Some(bar) = self.colorbar.as_mut() {
308                    bar.axis.title_option.offset_x += local_dx;
309                    bar.axis.title_option.offset_y += local_dy;
310                }
311            }
312            // Dispatched at the top of `nudge`.
313            Element::Axis(_)
314            | Element::ColorBarAxis
315            | Element::DataAreaEdge(_)
316            | Element::DataArea
317            | Element::ColorBarEdge(_) => {}
318        }
319        NudgeResult::Moved
320    }
321
322    /// Whole-data-area rule (drag-to-move): translate without resizing by
323    /// shifting opposite margins in tandem. Each axis component applies
324    /// independently and clamps at the chart edge (a blocked horizontal move
325    /// doesn't kill a valid vertical one), so the area slides along the
326    /// boundary like any draggable box.
327    fn nudge_data_area(&mut self, dx: f32, dy: f32) -> NudgeResult {
328        let mut moved = false;
329
330        if dx != 0.0 {
331            let new_left = self.left_y.out_margin + dx;
332            let new_right = self.right_y.out_margin - dx;
333            if new_left >= 0.0 && new_right >= 0.0 {
334                self.left_y.out_margin = new_left;
335                self.right_y.out_margin = new_right;
336                moved = true;
337            }
338        }
339        if dy != 0.0 {
340            let new_top = self.top_x.out_margin + dy;
341            let new_bottom = self.bottom_x.out_margin - dy;
342            if new_top >= 0.0 && new_bottom >= 0.0 {
343                self.top_x.out_margin = new_top;
344                self.bottom_x.out_margin = new_bottom;
345                moved = true;
346            }
347        }
348
349        if moved {
350            NudgeResult::Moved
351        } else {
352            NudgeResult::Rejected(NudgeReject::OutOfBounds)
353        }
354    }
355
356    /// Detached-axis drag rule: the axis line (with its ticks and labels)
357    /// moves only **perpendicular to itself** — y-axes horizontally, x-axes
358    /// vertically — via `line_offset`. The parallel component is discarded.
359    /// The data area / grid / data transform are untouched, so tick positions
360    /// along the axis stay aligned with the data; the constraint is only that
361    /// the axis line stays inside the chart area (it may cross into the data
362    /// area, e.g. a y-axis at x = 0).
363    fn nudge_axis(&mut self, side: Side, dx: f32, dy: f32) -> NudgeResult {
364        let d = match side {
365            Side::Left | Side::Right => dx,
366            Side::Top | Side::Bottom => dy,
367        };
368        if d == 0.0 {
369            // Pure parallel drag — nothing to move, but not an error.
370            return NudgeResult::Moved;
371        }
372        let Ok(da) = self.data_area() else {
373            return NudgeResult::Rejected(NudgeReject::OutOfBounds);
374        };
375        let new_offset = axis_ref(self, &side).line_offset + d;
376
377        // The shifted axis line must stay inside the chart area.
378        let line_pos = match side {
379            Side::Left => da.x as f32 + new_offset,
380            Side::Right => (da.x + da.width) as f32 + new_offset,
381            Side::Top => da.y as f32 + new_offset,
382            Side::Bottom => (da.y + da.height) as f32 + new_offset,
383        };
384        let ca = &self.chart_area;
385        let (lo, hi) = match side {
386            Side::Left | Side::Right => (ca.x as f32, (ca.x + ca.width) as f32),
387            Side::Top | Side::Bottom => (ca.y as f32, (ca.y + ca.height) as f32),
388        };
389        if line_pos < lo || line_pos > hi {
390            return NudgeResult::Rejected(NudgeReject::OutOfBounds);
391        }
392        axis_mut(self, &side).line_offset = new_offset;
393        NudgeResult::Moved
394    }
395
396    /// Colourbar-axis drag rule: detach the z-axis chrome perpendicular to the
397    /// strip without moving the strip, its title, the data area, or the z
398    /// transform. Labels follow the line/ticks because they share
399    /// `colorbar.axis.line_offset` in the renderer.
400    fn nudge_colorbar_axis(&mut self, dx: f32, dy: f32) -> NudgeResult {
401        let Some(bar) = self.colorbar.as_ref() else {
402            return NudgeResult::Rejected(NudgeReject::OutOfBounds);
403        };
404        let side = bar.side.clone();
405        let d = match side {
406            Side::Left | Side::Right => dx,
407            Side::Top | Side::Bottom => dy,
408        };
409        if d == 0.0 {
410            return NudgeResult::Moved;
411        }
412        let Ok(da) = self.data_area() else {
413            return NudgeResult::Rejected(NudgeReject::OutOfBounds);
414        };
415        let rect = colorbar_rect(&self.chart_area, &da, self.chart_title.top_margin, bar);
416        let new_offset = bar.axis.line_offset + d;
417        let line_pos = match side {
418            Side::Left => rect.x + new_offset,
419            Side::Right => rect.x + rect.width + new_offset,
420            Side::Top => rect.y + new_offset,
421            Side::Bottom => rect.y + rect.height + new_offset,
422        };
423        let ca = &self.chart_area;
424        let (lo, hi) = match side {
425            Side::Left | Side::Right => (ca.x as f32, (ca.x + ca.width) as f32),
426            Side::Top | Side::Bottom => (ca.y as f32, (ca.y + ca.height) as f32),
427        };
428        if line_pos < lo || line_pos > hi {
429            return NudgeResult::Rejected(NudgeReject::OutOfBounds);
430        }
431        self.colorbar.as_mut().expect("colourbar").axis.line_offset = new_offset;
432        NudgeResult::Moved
433    }
434
435    /// Colourbar-edge rule (resize handles): the dragged edge grows the
436    /// dimension it belongs to.
437    ///
438    /// Which dimension that is comes from the bar's orientation, not the handle:
439    /// on a vertical bar the left/right edges are `thickness_px` and the
440    /// top/bottom edges are `length_frac`. The sign is "away from the strip's
441    /// centre grows it", the same shape as `nudge_data_area_edge`'s per-side sign.
442    ///
443    /// The anchor decides how a size change moves the edge under the pointer, so
444    /// the delta is scaled to keep the two together: with `BarAlign::Center` both
445    /// ends move by half, so the size has to change by twice the drag. With
446    /// `Start` / `End` one end is pinned and the free end carries the whole
447    /// change — dragging the *pinned* end's handle still resizes, from the free
448    /// end, which is what an anchored box does.
449    ///
450    /// `thickness_px` needs no such factor. The band anchor pins the edge named
451    /// by `bar.side`, so changing thickness alone already makes the opposite,
452    /// inner edge follow its handle. When the pinned outer edge itself is
453    /// dragged, the strip is translated by the same pointer delta as the size
454    /// change. That keeps the opposite edge fixed and puts the grabbed edge
455    /// under the pointer instead of leaving it behind at the band anchor.
456    fn nudge_colorbar_edge(&mut self, side: Side, dx: f32, dy: f32) -> NudgeResult {
457        let Some(bar) = self.colorbar.as_ref() else {
458            return NudgeResult::Rejected(NudgeReject::OutOfBounds);
459        };
460        // Growth is the drag projected onto the edge's outward normal.
461        let growth = match side {
462            Side::Left => -dx,
463            Side::Right => dx,
464            Side::Top => -dy,
465            Side::Bottom => dy,
466        };
467        if growth == 0.0 {
468            // Motion parallel to this edge — nothing to resize, not an error.
469            return NudgeResult::Moved;
470        }
471        let bar_vertical = matches!(bar.side, Side::Left | Side::Right);
472        let edge_vertical = matches!(side, Side::Left | Side::Right);
473        // A vertical bar's left/right edges are its thickness; so are a
474        // horizontal bar's top/bottom edges.
475        let edge_is_thickness = bar_vertical == edge_vertical;
476
477        if edge_is_thickness {
478            let old_thickness = bar.thickness_px;
479            let old_offset_x = bar.offset_x;
480            let old_offset_y = bar.offset_y;
481            let new = old_thickness + growth;
482            if new < 0.0 {
483                return NudgeResult::Rejected(NudgeReject::OutOfBounds);
484            }
485            let dragged_outer_edge = bar.side == side;
486            let new_offset_x = if dragged_outer_edge && bar_vertical {
487                old_offset_x + dx
488            } else {
489                old_offset_x
490            };
491            let new_offset_y = if dragged_outer_edge && !bar_vertical {
492                old_offset_y + dy
493            } else {
494                old_offset_y
495            };
496            // Growing the strip grows its band, which shrinks the data area —
497            // so the same check `nudge_data_area_edge` makes applies, by the
498            // same write-then-revert (no Config clone on a pointer-move path).
499            // Thickness and the outer-handle offset are one transaction: a
500            // rejected resize must not leave the strip translated.
501            {
502                let bar = self.colorbar.as_mut().expect("colourbar");
503                bar.thickness_px = new;
504                bar.offset_x = new_offset_x;
505                bar.offset_y = new_offset_y;
506            }
507            if self.data_area().is_err() {
508                let bar = self.colorbar.as_mut().expect("colourbar");
509                bar.thickness_px = old_thickness;
510                bar.offset_x = old_offset_x;
511                bar.offset_y = old_offset_y;
512                return NudgeResult::Rejected(NudgeReject::OutOfBounds);
513            }
514            return NudgeResult::Moved;
515        }
516
517        let Ok(da) = self.data_area() else {
518            return NudgeResult::Rejected(NudgeReject::OutOfBounds);
519        };
520        let span = if bar_vertical {
521            da.height as f32
522        } else {
523            da.width as f32
524        };
525        if span <= 0.0 {
526            return NudgeResult::Rejected(NudgeReject::OutOfBounds);
527        }
528        let factor = match bar.align {
529            crate::config::BarAlign::Center => 2.0,
530            crate::config::BarAlign::Start | crate::config::BarAlign::End => 1.0,
531        };
532        let new = bar.length_frac + factor * growth / span;
533        // `Config::validate`'s range: a strip of zero length has no pixel to
534        // hold a colour, and one longer than its side runs past the axis it is
535        // labelled against.
536        if !(new > 0.0 && new <= 1.0) {
537            return NudgeResult::Rejected(NudgeReject::OutOfBounds);
538        }
539        self.colorbar.as_mut().expect("colourbar").length_frac = new;
540        NudgeResult::Moved
541    }
542
543    /// Data-area-edge rule (resize handles): one boundary moves by adjusting
544    /// that side's `out_margin`, perpendicular only. The data area — and with
545    /// it both the tick raster and the GPU data transform — derives from the
546    /// margins, so data and ticks move as one.
547    fn nudge_data_area_edge(&mut self, side: Side, dx: f32, dy: f32) -> NudgeResult {
548        // Screen delta → out_margin delta. Moving an edge toward the chart
549        // center grows its margin; toward the chart edge shrinks it.
550        let dm = match side {
551            Side::Left => dx,
552            Side::Right => -dx,
553            Side::Top => dy,
554            Side::Bottom => -dy,
555        };
556        if dm == 0.0 {
557            return NudgeResult::Moved;
558        }
559        let old = axis_ref(self, &side).out_margin;
560        let new = old + dm;
561        if new < 0.0 {
562            return NudgeResult::Rejected(NudgeReject::OutOfBounds);
563        }
564        axis_mut(self, &side).out_margin = new;
565        // The shifted margin must still leave a valid data area.
566        if self.data_area().is_err() {
567            axis_mut(self, &side).out_margin = old;
568            return NudgeResult::Rejected(NudgeReject::OutOfBounds);
569        }
570        NudgeResult::Moved
571    }
572
573    pub fn nudge_x(&mut self, element: Element, dx: f32) -> NudgeResult {
574        self.nudge(element, dx, 0.0)
575    }
576
577    pub fn nudge_y(&mut self, element: Element, dy: f32) -> NudgeResult {
578        self.nudge(element, 0.0, dy)
579    }
580}
581
582// Invariant tests.
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587    use crate::default::default_config;
588
589    // data_area() must be identical before and after nudge().
590    #[test]
591    fn nudge_preserves_data_area() {
592        let mut cfg = default_config();
593        let da_before = cfg.data_area().unwrap();
594        let r = cfg.nudge(Element::ChartTitle, 5.0, 3.0);
595        assert_eq!(r, NudgeResult::Moved);
596        let da_after = cfg.data_area().unwrap();
597        assert_eq!(da_before, da_after);
598    }
599
600    // On Rejected, Config must remain unchanged.
601    #[test]
602    fn nudge_rejected_preserves_state() {
603        let mut cfg = default_config();
604        let before = cfg.clone();
605        let r = cfg.nudge(Element::ChartTitle, -1e6, 0.0);
606        assert_eq!(r, NudgeResult::Rejected(NudgeReject::OutOfBounds));
607        assert_eq!(cfg, before);
608    }
609
610    // On Moved, only the target offset must change.
611    #[test]
612    fn nudge_moved_changes_only_target_offset() {
613        let mut cfg = default_config();
614        let before = cfg.clone();
615        cfg.nudge(Element::AxisLabel(Side::Left), 1.0, 0.0);
616        let mut expected = before.clone();
617        expected.left_y.label_style.label_offset_x += 1.0;
618        assert_eq!(cfg, expected);
619    }
620
621    // A y-axis moves only horizontally, *detached*: the parallel (dy)
622    // component is discarded, the motion lands in line_offset, and the data
623    // area does NOT move — the axis floats away while tick positions along
624    // the axis stay aligned with the data.
625    #[test]
626    fn axis_nudge_detaches_axis_and_keeps_data_area() {
627        let mut cfg = default_config();
628        let da_before = cfg.data_area().unwrap();
629        let margin_before = cfg.left_y.out_margin;
630
631        let r = cfg.nudge(Element::Axis(Side::Left), 10.0, 999.0);
632        assert_eq!(r, NudgeResult::Moved);
633        assert_eq!(cfg.left_y.line_offset, 10.0);
634        // Layout untouched — margin and data area identical.
635        assert_eq!(cfg.left_y.out_margin, margin_before);
636        assert_eq!(cfg.data_area().unwrap(), da_before);
637    }
638
639    // An x-axis moves only vertically (the dx component is discarded).
640    #[test]
641    fn bottom_axis_nudge_uses_dy_only() {
642        let mut cfg = default_config();
643        let r = cfg.nudge(Element::Axis(Side::Bottom), 999.0, -8.0);
644        assert_eq!(r, NudgeResult::Moved);
645        assert_eq!(cfg.bottom_x.line_offset, -8.0);
646        assert_eq!(
647            cfg.bottom_x.out_margin,
648            default_config().bottom_x.out_margin
649        );
650    }
651
652    // The detached axis may cross INTO the data area (e.g. y-axis at x = 0)
653    // but never leave the chart area.
654    #[test]
655    fn axis_nudge_allows_crossing_into_data_area() {
656        let mut cfg = default_config();
657        let r = cfg.nudge(Element::Axis(Side::Left), 50.0, 0.0);
658        assert_eq!(r, NudgeResult::Moved);
659        assert_eq!(cfg.left_y.line_offset, 50.0);
660    }
661
662    #[test]
663    fn axis_nudge_rejects_leaving_chart_area() {
664        let mut cfg = default_config();
665        let before = cfg.clone();
666        let r = cfg.nudge(Element::Axis(Side::Left), -1e6, 0.0);
667        assert_eq!(r, NudgeResult::Rejected(NudgeReject::OutOfBounds));
668        assert_eq!(cfg, before);
669    }
670
671    // Data-area edges (resize path) still move via margins.
672    #[test]
673    fn data_area_edge_nudge_moves_margin_and_data_area() {
674        let mut cfg = default_config();
675        let da_before = cfg.data_area().unwrap();
676        let margin_before = cfg.left_y.out_margin;
677
678        let r = cfg.nudge(Element::DataAreaEdge(Side::Left), 10.0, 999.0);
679        assert_eq!(r, NudgeResult::Moved);
680        assert_eq!(cfg.left_y.out_margin, margin_before + 10.0);
681        let da_after = cfg.data_area().unwrap();
682        assert_eq!(da_after.x, da_before.x + 10);
683        assert_eq!(da_after.width, da_before.width - 10);
684    }
685
686    #[test]
687    fn data_area_edge_rejects_negative_margin() {
688        let mut cfg = default_config();
689        let before = cfg.clone();
690        let r = cfg.nudge(Element::DataAreaEdge(Side::Left), -1e6, 0.0);
691        assert_eq!(r, NudgeResult::Rejected(NudgeReject::OutOfBounds));
692        assert_eq!(cfg, before);
693    }
694
695    #[test]
696    fn data_area_edge_rejects_margin_overflow() {
697        let mut cfg = default_config();
698        let before = cfg.clone();
699        let w = cfg.chart_area.width as f32;
700        let r = cfg.nudge(Element::DataAreaEdge(Side::Left), w * 2.0, 0.0);
701        assert_eq!(r, NudgeResult::Rejected(NudgeReject::OutOfBounds));
702        assert_eq!(cfg, before);
703    }
704}