Skip to main content

model/
resize.rs

1//! Resize policy — `Resizable`, PPT-style 8-handle resizing for selected
2//! elements.
3//!
4//! A `Resizable` element's selection box grows eight square handles (four
5//! corners + four edge midpoints). Dragging a handle resizes the element; as
6//! with [`Draggable`](crate::drag::Draggable), the actual mutation routes
7//! through [`Config::nudge`] only — implementations just map each handle to
8//! the nudge elements its horizontal / vertical motion drives. For the data
9//! area that mapping is its boundary edges ([`Element::DataAreaEdge`]):
10//! dragging the east handle moves the right boundary, the north-west corner
11//! moves the left and top boundaries, and so on — margins shift, the data
12//! area follows, and ticks/data stay aligned by construction.
13
14use crate::config::Config;
15use crate::drag::DragConfig;
16use crate::layout::{Element, NudgeReject, NudgeResult, RectF, Side};
17use crate::select::{ColorBarElement, DataAreaElement, SELECTION_PADDING, Selectable};
18use crate::text::MeasureText;
19
20/// Edge length of a square resize handle, px.
21pub const HANDLE_SIZE: f32 = 8.0;
22
23/// One of the eight resize handles, compass-named.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ResizeHandle {
26    NW,
27    N,
28    NE,
29    E,
30    SE,
31    S,
32    SW,
33    W,
34}
35
36pub const ALL_HANDLES: [ResizeHandle; 8] = [
37    ResizeHandle::NW,
38    ResizeHandle::N,
39    ResizeHandle::NE,
40    ResizeHandle::E,
41    ResizeHandle::SE,
42    ResizeHandle::S,
43    ResizeHandle::SW,
44    ResizeHandle::W,
45];
46
47/// The eight handle squares for a selection rect: corners + edge midpoints,
48/// each centered on its anchor point.
49pub fn handle_rects(rect: &RectF) -> [(ResizeHandle, RectF); 8] {
50    let (x0, y0) = (rect.x, rect.y);
51    let (x1, y1) = (rect.x + rect.width, rect.y + rect.height);
52    let (cx, cy) = (rect.x + rect.width * 0.5, rect.y + rect.height * 0.5);
53    let half = HANDLE_SIZE * 0.5;
54    let square = |x: f32, y: f32| RectF {
55        x: x - half,
56        y: y - half,
57        width: HANDLE_SIZE,
58        height: HANDLE_SIZE,
59    };
60    [
61        (ResizeHandle::NW, square(x0, y0)),
62        (ResizeHandle::N, square(cx, y0)),
63        (ResizeHandle::NE, square(x1, y0)),
64        (ResizeHandle::E, square(x1, cy)),
65        (ResizeHandle::SE, square(x1, y1)),
66        (ResizeHandle::S, square(cx, y1)),
67        (ResizeHandle::SW, square(x0, y1)),
68        (ResizeHandle::W, square(x0, cy)),
69    ]
70}
71
72/// A selectable element whose extent can be adjusted by dragging the eight
73/// handles on its selection box.
74pub trait Resizable<C: DragConfig = Config>: Selectable<C> {
75    /// The nudge elements a handle's (horizontal, vertical) motion drives —
76    /// the only thing an implementation defines. `None` components are
77    /// discarded (an edge-midpoint handle resizes along one dimension).
78    fn resize_targets(&self, handle: ResizeHandle) -> (Option<C::Element>, Option<C::Element>);
79
80    /// Default handle geometry: eight squares on the selection box (element
81    /// bounds + [`SELECTION_PADDING`]).
82    fn resize_handles(
83        &self,
84        cfg: &C,
85        measure: &dyn MeasureText,
86    ) -> Option<[(ResizeHandle, RectF); 8]> {
87        let rect = self.bounds(cfg, measure)?.expanded(SELECTION_PADDING);
88        Some(handle_rects(&rect))
89    }
90
91    /// Default handle hit test.
92    fn hit_resize_handle(
93        &self,
94        cfg: &C,
95        measure: &dyn MeasureText,
96        x: f32,
97        y: f32,
98    ) -> Option<ResizeHandle> {
99        self.resize_handles(cfg, measure)?
100            .iter()
101            .find(|(_, r)| r.contains(x, y))
102            .map(|(h, _)| *h)
103    }
104
105    /// Default resize pipeline: each motion component routes through
106    /// [`Config::nudge`] on its target element, so resize obeys exactly the
107    /// same feasibility rules as dragging those elements directly.
108    fn resize_by(&self, cfg: &mut C, handle: ResizeHandle, dx: f32, dy: f32) -> NudgeResult {
109        let (h_target, v_target) = self.resize_targets(handle);
110        let mut moved = false;
111        let mut attempted = false;
112        if dx != 0.0
113            && let Some(e) = h_target
114        {
115            attempted = true;
116            moved |= cfg.nudge(e, dx, 0.0) == NudgeResult::Moved;
117        }
118        if dy != 0.0
119            && let Some(e) = v_target
120        {
121            attempted = true;
122            moved |= cfg.nudge(e, 0.0, dy) == NudgeResult::Moved;
123        }
124        if moved || !attempted {
125            NudgeResult::Moved
126        } else {
127            NudgeResult::Rejected(NudgeReject::OutOfBounds)
128        }
129    }
130}
131
132impl Resizable for ColorBarElement {
133    /// Each handle drives the strip edges it touches — the same compass mapping
134    /// the data area uses. Which of `thickness_px` / `length_frac` an edge
135    /// changes depends on the bar's orientation, and that resolution belongs to
136    /// nudge: a handle only knows screen directions.
137    fn resize_targets(&self, handle: ResizeHandle) -> (Option<Element>, Option<Element>) {
138        use ResizeHandle::*;
139        let horizontal = match handle {
140            NW | W | SW => Some(Element::ColorBarEdge(Side::Left)),
141            NE | E | SE => Some(Element::ColorBarEdge(Side::Right)),
142            N | S => None,
143        };
144        let vertical = match handle {
145            NW | N | NE => Some(Element::ColorBarEdge(Side::Top)),
146            SW | S | SE => Some(Element::ColorBarEdge(Side::Bottom)),
147            E | W => None,
148        };
149        (horizontal, vertical)
150    }
151}
152
153impl Resizable for DataAreaElement {
154    /// Each handle drives the boundary edges it touches.
155    fn resize_targets(&self, handle: ResizeHandle) -> (Option<Element>, Option<Element>) {
156        use ResizeHandle::*;
157        let horizontal = match handle {
158            NW | W | SW => Some(Element::DataAreaEdge(Side::Left)),
159            NE | E | SE => Some(Element::DataAreaEdge(Side::Right)),
160            N | S => None,
161        };
162        let vertical = match handle {
163            NW | N | NE => Some(Element::DataAreaEdge(Side::Top)),
164            SW | S | SE => Some(Element::DataAreaEdge(Side::Bottom)),
165            E | W => None,
166        };
167        (horizontal, vertical)
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::default::default_config;
175    use crate::layout::{ChartArea, Rect};
176    use crate::text::{RichText, TextExtents};
177
178    struct FixedMeasure;
179    impl MeasureText for FixedMeasure {
180        fn measure_rich(&self, rt: &RichText) -> TextExtents {
181            TextExtents {
182                width: rt.segments.len() as f32 * 8.0,
183                ascent: 10.0,
184                descent: 3.0,
185            }
186        }
187    }
188
189    fn cfg_800x600() -> Config {
190        let mut cfg = default_config();
191        cfg.chart_area = ChartArea(Rect {
192            x: 0,
193            y: 0,
194            width: 800,
195            height: 600,
196        });
197        cfg
198    }
199
200    #[test]
201    fn handles_sit_on_selection_box_corners_and_midpoints() {
202        let cfg = cfg_800x600();
203        let b = DataAreaElement
204            .bounds(&cfg, &FixedMeasure)
205            .unwrap()
206            .expanded(SELECTION_PADDING);
207        let handles = DataAreaElement.resize_handles(&cfg, &FixedMeasure).unwrap();
208        assert_eq!(handles.len(), 8);
209
210        let (_, nw) = handles[0];
211        assert_eq!(nw.x + HANDLE_SIZE * 0.5, b.x);
212        assert_eq!(nw.y + HANDLE_SIZE * 0.5, b.y);
213        let (_, e) = handles[3];
214        assert_eq!(e.x + HANDLE_SIZE * 0.5, b.x + b.width);
215        assert_eq!(e.y + HANDLE_SIZE * 0.5, b.y + b.height * 0.5);
216    }
217
218    #[test]
219    fn hit_resize_handle_finds_corner() {
220        let cfg = cfg_800x600();
221        let handles = DataAreaElement.resize_handles(&cfg, &FixedMeasure).unwrap();
222        let (kind, rect) = handles[4]; // SE
223        let hit = DataAreaElement
224            .hit_resize_handle(
225                &cfg,
226                &FixedMeasure,
227                rect.x + rect.width * 0.5,
228                rect.y + rect.height * 0.5,
229            )
230            .unwrap();
231        assert_eq!(hit, kind);
232        assert!(
233            DataAreaElement
234                .hit_resize_handle(&cfg, &FixedMeasure, -100.0, -100.0)
235                .is_none()
236        );
237    }
238
239    // Dragging the east handle left shrinks the data area by growing the
240    // right margin — and only horizontally (the dy component is discarded).
241    #[test]
242    fn east_handle_resizes_via_right_axis() {
243        let mut cfg = cfg_800x600();
244        let da_before = cfg.data_area().unwrap();
245        let r = DataAreaElement.resize_by(&mut cfg, ResizeHandle::E, -10.0, 99.0);
246        assert_eq!(r, NudgeResult::Moved);
247        let da_after = cfg.data_area().unwrap();
248        assert_eq!(da_after.width, da_before.width - 10);
249        assert_eq!(da_after.height, da_before.height);
250        assert_eq!(da_after.x, da_before.x);
251    }
252
253    // A corner handle drives both boundary axes at once.
254    #[test]
255    fn nw_corner_resizes_left_and_top() {
256        let mut cfg = cfg_800x600();
257        let da_before = cfg.data_area().unwrap();
258        let r = DataAreaElement.resize_by(&mut cfg, ResizeHandle::NW, 6.0, 4.0);
259        assert_eq!(r, NudgeResult::Moved);
260        let da_after = cfg.data_area().unwrap();
261        assert_eq!(da_after.x, da_before.x + 6);
262        assert_eq!(da_after.y, da_before.y + 4);
263        assert_eq!(da_after.width, da_before.width - 6);
264        assert_eq!(da_after.height, da_before.height - 4);
265    }
266
267    // Resizing past the chart edge is rejected and leaves the config intact.
268    #[test]
269    fn resize_rejected_when_margin_would_go_negative() {
270        let mut cfg = cfg_800x600();
271        let before = cfg.clone();
272        let r = DataAreaElement.resize_by(&mut cfg, ResizeHandle::E, 1e6, 0.0);
273        assert_eq!(r, NudgeResult::Rejected(NudgeReject::OutOfBounds));
274        assert_eq!(cfg, before);
275    }
276
277    // ── Colourbar ──────────────────────────────────────────────────────────
278
279    fn cfg_with_colorbar(side: Side) -> Config {
280        let mut cfg = cfg_800x600();
281        let mut bar = crate::default::default_colorbar_options();
282        bar.side = side;
283        cfg.colorbar = Some(bar);
284        cfg
285    }
286
287    fn bar_of(cfg: &Config) -> &crate::config::ColorBarOptions {
288        cfg.colorbar.as_ref().expect("colourbar")
289    }
290
291    fn colorbar_bounds(cfg: &Config) -> RectF {
292        ColorBarElement
293            .bounds(cfg, &FixedMeasure)
294            .expect("strip bounds")
295    }
296
297    fn colorbar_short_edges(rect: RectF, side: &Side) -> (f32, f32) {
298        match side {
299            Side::Left => (rect.x + rect.width, rect.x),
300            Side::Right => (rect.x, rect.x + rect.width),
301            Side::Top => (rect.y + rect.height, rect.y),
302            Side::Bottom => (rect.y, rect.y + rect.height),
303        }
304    }
305
306    fn assert_near(actual: f32, expected: f32, context: &str) {
307        assert!(
308            (actual - expected).abs() < 1e-3,
309            "{context}: got {actual}, expected {expected}"
310        );
311    }
312
313    /// Which dimension a handle drives follows the *bar's* orientation, not the
314    /// handle: on a vertical bar the side handles are thickness and the end
315    /// handles are length; on a horizontal bar it is the other way round.
316    #[test]
317    fn colorbar_handles_drive_thickness_or_length_by_orientation() {
318        for (side, thickness_handle, length_handle) in [
319            (Side::Right, ResizeHandle::W, ResizeHandle::N),
320            (Side::Left, ResizeHandle::E, ResizeHandle::S),
321            (Side::Top, ResizeHandle::S, ResizeHandle::E),
322            (Side::Bottom, ResizeHandle::N, ResizeHandle::W),
323        ] {
324            let base = cfg_with_colorbar(side.clone());
325            let (t0, l0) = (bar_of(&base).thickness_px, bar_of(&base).length_frac);
326
327            // The thickness handle changes thickness and leaves length alone.
328            let mut cfg = base.clone();
329            assert_eq!(
330                ColorBarElement.resize_by(&mut cfg, thickness_handle, -4.0, -4.0),
331                NudgeResult::Moved,
332                "{side:?} {thickness_handle:?}"
333            );
334            assert_ne!(bar_of(&cfg).thickness_px, t0, "{side:?} thickness");
335            assert_eq!(bar_of(&cfg).length_frac, l0, "{side:?} length untouched");
336
337            // And the length handle the other way.
338            let mut cfg = base.clone();
339            assert_eq!(
340                ColorBarElement.resize_by(&mut cfg, length_handle, -4.0, -4.0),
341                NudgeResult::Moved,
342                "{side:?} {length_handle:?}"
343            );
344            assert_eq!(
345                bar_of(&cfg).thickness_px,
346                t0,
347                "{side:?} thickness untouched"
348            );
349            assert_ne!(bar_of(&cfg).length_frac, l0, "{side:?} length");
350        }
351    }
352
353    /// Dragging an edge outward grows the bar; inward shrinks it. Stated on the
354    /// drawn rect, because that is what the user is dragging.
355    #[test]
356    fn dragging_a_colorbar_edge_outward_grows_it() {
357        // Vertical bar, west handle: dragging left (away from the strip) widens.
358        let mut cfg = cfg_with_colorbar(Side::Right);
359        let before = colorbar_bounds(&cfg);
360        ColorBarElement.resize_by(&mut cfg, ResizeHandle::W, -6.0, 0.0);
361        let after = colorbar_bounds(&cfg);
362        assert!(
363            after.width > before.width,
364            "west drag left must widen: {} -> {}",
365            before.width,
366            after.width
367        );
368
369        let mut cfg = cfg_with_colorbar(Side::Right);
370        ColorBarElement.resize_by(&mut cfg, ResizeHandle::W, 6.0, 0.0);
371        assert!(
372            colorbar_bounds(&cfg).width < before.width,
373            "west drag right narrows"
374        );
375    }
376
377    /// Every thickness handle follows the pointer on screen. The edge opposite
378    /// the grabbed handle stays fixed, including when the grabbed edge is the
379    /// strip's band-anchored outer edge.
380    #[test]
381    fn colorbar_thickness_handles_track_the_pointer_on_all_sides() {
382        for (side, outer_handle, outer_dx, outer_dy) in [
383            (Side::Left, ResizeHandle::W, -6.0, 0.0),
384            (Side::Right, ResizeHandle::E, 6.0, 0.0),
385            (Side::Top, ResizeHandle::N, 0.0, -6.0),
386            (Side::Bottom, ResizeHandle::S, 0.0, 6.0),
387        ] {
388            let pointer_delta = if outer_dx != 0.0 { outer_dx } else { outer_dy };
389
390            let mut cfg = cfg_with_colorbar(side.clone());
391            let before = colorbar_bounds(&cfg);
392            let (inner_before, outer_before) = colorbar_short_edges(before, &side);
393            assert_eq!(
394                ColorBarElement.resize_by(&mut cfg, outer_handle, outer_dx, outer_dy),
395                NudgeResult::Moved,
396                "{side:?} outer handle"
397            );
398            let after = colorbar_bounds(&cfg);
399            let (inner_after, outer_after) = colorbar_short_edges(after, &side);
400            assert_near(inner_after, inner_before, &format!("{side:?} inner edge"));
401            assert_near(
402                outer_after,
403                outer_before + pointer_delta,
404                &format!("{side:?} outer edge"),
405            );
406
407            let inner_handle = match side {
408                Side::Left => ResizeHandle::E,
409                Side::Right => ResizeHandle::W,
410                Side::Top => ResizeHandle::S,
411                Side::Bottom => ResizeHandle::N,
412            };
413            let mut cfg = cfg_with_colorbar(side.clone());
414            let before = colorbar_bounds(&cfg);
415            let (inner_before, outer_before) = colorbar_short_edges(before, &side);
416            assert_eq!(
417                ColorBarElement.resize_by(&mut cfg, inner_handle, -outer_dx, -outer_dy),
418                NudgeResult::Moved,
419                "{side:?} inner handle"
420            );
421            let after = colorbar_bounds(&cfg);
422            let (inner_after, outer_after) = colorbar_short_edges(after, &side);
423            assert_near(
424                inner_after,
425                inner_before - pointer_delta,
426                &format!("{side:?} inner edge"),
427            );
428            assert_near(outer_after, outer_before, &format!("{side:?} outer edge"));
429        }
430    }
431
432    /// With a centre anchor both ends move by half a size change, so the size
433    /// has to change by twice the drag for the dragged edge to stay under the
434    /// pointer. With `Start` / `End` one end is pinned and the factor is 1.
435    #[test]
436    fn a_colorbar_end_handle_tracks_the_pointer_under_its_anchor() {
437        use crate::config::BarAlign;
438
439        for (align, factor) in [
440            (BarAlign::Center, 2.0f32),
441            (BarAlign::Start, 1.0),
442            (BarAlign::End, 1.0),
443        ] {
444            let mut cfg = cfg_with_colorbar(Side::Right);
445            cfg.colorbar.as_mut().expect("colourbar").align = align.clone();
446            let da = cfg.data_area().expect("data area");
447            let before = bar_of(&cfg).length_frac;
448
449            let drag = -10.0f32; // north handle, upward = grow
450            ColorBarElement.resize_by(&mut cfg, ResizeHandle::N, 0.0, drag);
451            let grew = (bar_of(&cfg).length_frac - before) * da.height as f32;
452            assert!(
453                (grew - factor * -drag).abs() < 1e-3,
454                "{align:?}: grew {grew} px, expected {}",
455                factor * -drag
456            );
457        }
458    }
459
460    /// The size range is the one `Config::validate` states, and a handle drag
461    /// cannot leave it: rejected, with the config untouched.
462    #[test]
463    fn colorbar_resize_stops_at_the_valid_range() {
464        // Length cannot exceed the data area.
465        let mut cfg = cfg_with_colorbar(Side::Right);
466        cfg.colorbar.as_mut().expect("colourbar").length_frac = 1.0;
467        let before = cfg.clone();
468        assert_eq!(
469            ColorBarElement.resize_by(&mut cfg, ResizeHandle::N, 0.0, -5.0),
470            NudgeResult::Rejected(NudgeReject::OutOfBounds)
471        );
472        assert_eq!(cfg, before);
473
474        // Thickness cannot go negative.
475        let mut cfg = cfg_with_colorbar(Side::Right);
476        cfg.colorbar.as_mut().expect("colourbar").thickness_px = 2.0;
477        let before = cfg.clone();
478        assert_eq!(
479            ColorBarElement.resize_by(&mut cfg, ResizeHandle::W, 50.0, 0.0),
480            NudgeResult::Rejected(NudgeReject::OutOfBounds)
481        );
482        assert_eq!(cfg, before);
483
484        // A rejected outer-edge resize rolls back its paired offset as well as
485        // the thickness; otherwise the bar would jump despite the rejection.
486        let mut cfg = cfg_with_colorbar(Side::Right);
487        let before = cfg.clone();
488        assert_eq!(
489            ColorBarElement.resize_by(&mut cfg, ResizeHandle::E, 1e6, 0.0),
490            NudgeResult::Rejected(NudgeReject::OutOfBounds)
491        );
492        assert_eq!(cfg, before);
493
494        // And the result always satisfies `validate`.
495        let mut cfg = cfg_with_colorbar(Side::Right);
496        for _ in 0..40 {
497            ColorBarElement.resize_by(&mut cfg, ResizeHandle::NW, -3.0, -3.0);
498            assert!(cfg.validate().is_ok(), "resize left an invalid config");
499        }
500    }
501
502    /// A corner handle drives both dimensions in one gesture.
503    #[test]
504    fn a_colorbar_corner_handle_resizes_both_dimensions() {
505        let mut cfg = cfg_with_colorbar(Side::Right);
506        let (t0, l0) = (bar_of(&cfg).thickness_px, bar_of(&cfg).length_frac);
507        assert_eq!(
508            ColorBarElement.resize_by(&mut cfg, ResizeHandle::NW, -5.0, -5.0),
509            NudgeResult::Moved
510        );
511        assert!(bar_of(&cfg).thickness_px > t0);
512        assert!(bar_of(&cfg).length_frac > l0);
513    }
514
515    /// The outer component of a corner handle obeys the same pointer-tracking
516    /// rule while its long-axis component resizes in the same gesture.
517    #[test]
518    fn a_colorbar_outer_corner_tracks_both_pointer_components() {
519        let mut cfg = cfg_with_colorbar(Side::Right);
520        let before = colorbar_bounds(&cfg);
521        assert_eq!(
522            ColorBarElement.resize_by(&mut cfg, ResizeHandle::NE, 5.0, -7.0),
523            NudgeResult::Moved
524        );
525        let after = colorbar_bounds(&cfg);
526        assert_near(after.x, before.x, "opposite thickness edge");
527        assert_near(
528            after.x + after.width,
529            before.x + before.width + 5.0,
530            "outer thickness edge",
531        );
532        assert_near(after.y, before.y - 7.0, "north length edge");
533    }
534}