Skip to main content

model/
select.rs

1//! Selection policy — `Selectable` and the chart elements that implement it.
2//!
3//! Selectable objects expose their interaction-policy pixel bounds (`bounds`); what
4//! selection *does* is fixed by the trait's default methods so it applies
5//! uniformly to every element: a blue highlight box around the bounds
6//! (`selection_box`) and point hit-testing (`contains`). The renderer draws
7//! the box with skia (`axis_render::draw_selection_boxes`); the model only
8//! states the policy.
9//!
10//! Placement formulas shared with the renderer consume glyph extents through
11//! the [`MeasureText`] contract. Tick labels intentionally use a representative
12//! strip instead of reproducing renderer-owned tick generation, formatting,
13//! pruning, or the exact union of rendered labels.
14
15use crate::color::Color;
16use crate::config::{AxisOptions, Config, TickVisibility};
17use crate::drag::{DragConfig, Draggable};
18use crate::layout::{
19    RectF, Side, TitleBand, axis_offset, axis_title_placement, axis_visibility_rect,
20    chart_title_placement, colorbar_rect, colorbar_title_placement, label_origin, label_rect,
21    legend_rect, point_on_rect_side, rect_axis_visibility_rect,
22};
23use crate::resize::Resizable;
24use crate::text::MeasureText;
25
26/// Selection highlight color (blue).
27pub const SELECTION_COLOR: Color = Color {
28    r: 0.13,
29    g: 0.47,
30    b: 0.95,
31    a: 1.0,
32};
33/// Stroke width of the highlight box, px.
34pub const SELECTION_STROKE_WIDTH: f32 = 1.5;
35/// Gap between the element bounds and the highlight box, px.
36pub const SELECTION_PADDING: f32 = 2.0;
37
38/// The blue box drawn around a selected element. Produced by
39/// [`Selectable::selection_box`]; consumed by the renderer's skia pass.
40#[derive(Debug, Clone, PartialEq)]
41pub struct SelectionBox {
42    /// Box rect in chart-surface pixels (already padding-expanded).
43    pub rect: RectF,
44    pub color: Color,
45    pub stroke_width: f32,
46    /// Resize handle squares (empty for non-resizable elements). Drawn as
47    /// filled squares on top of the box outline.
48    pub handles: Vec<RectF>,
49}
50
51/// An object the user can select on the chart.
52///
53/// Implementations provide [`Self::bounds`] only. Selection behavior lives in
54/// the default methods so it is identical across elements; override them only
55/// to change the policy itself.
56pub trait Selectable<C: DragConfig = Config> {
57    /// Interaction bounds of this element under `cfg`, or `None` when the
58    /// element is hidden, empty, or the layout is infeasible. Text-bearing
59    /// elements use `measure`; tick-label elements remain representative
60    /// strips rather than exact rendered-label unions.
61    fn bounds(&self, cfg: &C, measure: &dyn MeasureText) -> Option<RectF>;
62
63    /// Default selection policy: a blue box [`SELECTION_PADDING`] px outside
64    /// the bounds, stroked [`SELECTION_STROKE_WIDTH`] px in
65    /// [`SELECTION_COLOR`]. Resizable elements additionally carry the eight
66    /// resize handle squares.
67    fn selection_box(&self, cfg: &C, measure: &dyn MeasureText) -> Option<SelectionBox> {
68        let rect = self.bounds(cfg, measure)?.expanded(SELECTION_PADDING);
69        let handles = match self.as_resizable() {
70            Some(_) => crate::resize::handle_rects(&rect)
71                .iter()
72                .map(|(_, r)| *r)
73                .collect(),
74            None => Vec::new(),
75        };
76        Some(SelectionBox {
77            rect,
78            color: SELECTION_COLOR,
79            stroke_width: SELECTION_STROKE_WIDTH,
80            handles,
81        })
82    }
83
84    /// Default hit test: point-in-bounds.
85    fn contains(&self, cfg: &C, measure: &dyn MeasureText, x: f32, y: f32) -> bool {
86        self.bounds(cfg, measure).is_some_and(|b| b.contains(x, y))
87    }
88
89    /// Default registration: every `Selectable` can enter a [`HitMap`] the
90    /// same way. Returns the id the map will report from `hit_test`.
91    /// (`Send + Sync` because hosts store hit maps in cross-thread render
92    /// state, e.g. egui's `CallbackResources`.)
93    fn register_into(self, map: &mut HitMap<C>) -> HitId
94    where
95        Self: Sized + Send + Sync + 'static,
96    {
97        map.register(self)
98    }
99
100    /// This element's drag capability, if any. Elements that also implement
101    /// [`Draggable`] override this with `Some(self)`. Lets hosts go from a
102    /// [`HitMap`] hit straight to dragging without knowing concrete types.
103    fn as_draggable(&self) -> Option<&dyn Draggable<C>> {
104        None
105    }
106
107    /// This element's resize capability, if any. Elements that also implement
108    /// [`Resizable`] override this with `Some(self)` — their selection box
109    /// then grows the eight resize handles automatically.
110    fn as_resizable(&self) -> Option<&dyn Resizable<C>> {
111        None
112    }
113
114    /// Stable element name for hosts that key on identity rather than
115    /// [`HitId`] (e.g. a wasm host exposing hit-testing as strings):
116    /// `"data_area"`, `"axis_bottom"`, `"tick_labels_left"`,
117    /// `"axis_title_left"`, `"legend"`, `"chart_title"`,
118    /// `"colorbar_axis"`, `"colorbar_tick_labels"`, `"colorbar_title"`.
119    /// Custom host elements keep the default.
120    fn element_id(&self) -> String {
121        "custom".to_string()
122    }
123}
124
125fn side_str(side: &Side) -> &'static str {
126    match side {
127        Side::Top => "top",
128        Side::Bottom => "bottom",
129        Side::Left => "left",
130        Side::Right => "right",
131    }
132}
133
134/// Id of an entry registered in a [`HitMap`]. Stable for the map's lifetime.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub struct HitId(usize);
137
138/// Registry of selectable elements for one chart panel + point hit-testing.
139///
140/// Hosts feed pointer events here: `hit_test` returns the topmost registered
141/// element under the point (later registrations win — register background
142/// elements like the data area first, small foreground elements like titles
143/// last). The model owns this because hit-testing is pure bounds geometry;
144/// the renderer only supplies the [`MeasureText`] implementation.
145pub struct HitMap<C: DragConfig = Config> {
146    entries: Vec<Box<dyn Selectable<C> + Send + Sync>>,
147}
148
149impl<C: DragConfig> HitMap<C> {
150    /// Create a map for an explicit state type; Cartesian callers keep `new()`.
151    pub fn for_state() -> Self {
152        Self {
153            entries: Vec::new(),
154        }
155    }
156
157    pub fn register(&mut self, el: impl Selectable<C> + Send + Sync + 'static) -> HitId {
158        self.entries.push(Box::new(el));
159        HitId(self.entries.len() - 1)
160    }
161
162    /// Topmost element containing `(x, y)` — entries are tested in reverse
163    /// registration order.
164    pub fn hit_test(&self, cfg: &C, measure: &dyn MeasureText, x: f32, y: f32) -> Option<HitId> {
165        self.entries
166            .iter()
167            .enumerate()
168            .rev()
169            .find(|(_, el)| el.contains(cfg, measure, x, y))
170            .map(|(i, _)| HitId(i))
171    }
172
173    pub fn get(&self, id: HitId) -> Option<&(dyn Selectable<C> + Send + Sync)> {
174        self.entries.get(id.0).map(|b| b.as_ref())
175    }
176
177    /// Selection highlight for a registered element — the registered
178    /// element's [`Selectable::selection_box`] policy.
179    pub fn selection_box(
180        &self,
181        id: HitId,
182        cfg: &C,
183        measure: &dyn MeasureText,
184    ) -> Option<SelectionBox> {
185        self.get(id)?.selection_box(cfg, measure)
186    }
187
188    pub fn len(&self) -> usize {
189        self.entries.len()
190    }
191
192    pub fn is_empty(&self) -> bool {
193        self.entries.is_empty()
194    }
195}
196
197impl Default for HitMap<Config> {
198    fn default() -> Self {
199        Self::new()
200    }
201}
202
203impl HitMap<Config> {
204    pub fn new() -> Self {
205        Self::for_state()
206    }
207    /// The standard registration set for a single chart panel, back-to-front:
208    /// data area → axes → tick-label bands → axis titles → legend → chart
209    /// title → colourbar strip → colourbar axis → colourbar labels →
210    /// colourbar title.
211    ///
212    /// Colourbar chrome is last, so it wins over anything it overlaps. Its
213    /// smaller parts follow the strip, allowing the ticks, labels, and title to
214    /// be selected independently while the strip remains the resize target.
215    pub fn standard_chart() -> Self {
216        let mut map = Self::new();
217        map.register(DataAreaElement);
218        for side in [Side::Top, Side::Bottom, Side::Left, Side::Right] {
219            map.register(AxisElement { side });
220        }
221        for side in [Side::Top, Side::Bottom, Side::Left, Side::Right] {
222            map.register(AxisLabelElement { side });
223        }
224        for side in [Side::Top, Side::Bottom, Side::Left, Side::Right] {
225            map.register(AxisTitleElement { side });
226        }
227        map.register(LegendElement);
228        map.register(ChartTitleElement);
229        map.register(ColorBarElement);
230        map.register(ColorBarAxisElement);
231        map.register(ColorBarLabelElement);
232        map.register(ColorBarTitleElement);
233        map
234    }
235}
236
237// Selectable chart elements.
238
239/// One of the four axes — the axis line plus its tick band.
240#[derive(Debug, Clone, PartialEq)]
241pub struct AxisElement {
242    pub side: Side,
243}
244
245/// One axis title (rotated 90° on Left/Right sides).
246#[derive(Debug, Clone, PartialEq)]
247pub struct AxisTitleElement {
248    pub side: Side,
249}
250
251/// One axis's tick-value labels — the band the numeric labels live in,
252/// between the tick ends and the outer chart edge.
253#[derive(Debug, Clone, PartialEq)]
254pub struct AxisLabelElement {
255    pub side: Side,
256}
257
258/// The chart title in the top band.
259#[derive(Debug, Clone, PartialEq)]
260pub struct ChartTitleElement;
261
262/// The data plotting area (everything inside the margins).
263#[derive(Debug, Clone, PartialEq)]
264pub struct DataAreaElement;
265
266/// The legend box in one corner of the data area.
267#[derive(Debug, Clone, PartialEq)]
268pub struct LegendElement;
269
270/// The colourbar strip.
271///
272/// Carries no side of its own: the bar's `side` lives in `Config.colorbar`, and
273/// a copy here would be a mirror that can disagree with what is drawn. That is
274/// also why the resize handles map to screen-side edges
275/// ([`Element::ColorBarEdge`](crate::layout::Element::ColorBarEdge)) and let
276/// nudge resolve which dimension each one drives.
277#[derive(Debug, Clone, PartialEq)]
278pub struct ColorBarElement;
279
280/// The colourbar axis line plus its major/minor tick band.
281///
282/// Like [`ColorBarElement`], this carries no mirrored `Side`; orientation is
283/// always read from `Config.colorbar` at the point of use.
284#[derive(Debug, Clone, PartialEq)]
285pub struct ColorBarAxisElement;
286
287/// The colourbar's tick-value label band.
288#[derive(Debug, Clone, PartialEq)]
289pub struct ColorBarLabelElement;
290
291/// The colourbar title, rotated on vertical bars.
292#[derive(Debug, Clone, PartialEq)]
293pub struct ColorBarTitleElement;
294
295fn axis_of<'a>(cfg: &'a Config, side: &Side) -> &'a AxisOptions {
296    match side {
297        Side::Top => &cfg.top_x,
298        Side::Bottom => &cfg.bottom_x,
299        Side::Left => &cfg.left_y,
300        Side::Right => &cfg.right_y,
301    }
302}
303
304fn representative_label_extents(
305    axis: &AxisOptions,
306    measure: &dyn MeasureText,
307) -> crate::text::TextExtents {
308    let ls = &axis.label_style;
309    let sample_text = match &ls.format {
310        crate::format::LabelFormat::Timestamp(_) => "0000-00-00 00:00:00.000".to_string(),
311        _ => {
312            let digits = (ls.significant_digits.max(1) as usize) + 2;
313            "0".repeat(digits)
314        }
315    };
316    let sample = crate::text::RichText {
317        segments: crate::text::rich_segments_from_text(&sample_text),
318        color: ls.color,
319        font_size: ls.font_size,
320        font: ls.label_font.clone(),
321    };
322    measure.measure_rich(&sample)
323}
324
325fn visible_colorbar_rect(cfg: &Config) -> Option<(&crate::config::ColorBarOptions, RectF)> {
326    let bar = cfg.colorbar.as_ref()?;
327    if !bar.visible {
328        return None;
329    }
330    let da = cfg.data_area().ok()?;
331    let rect = colorbar_rect(&cfg.chart_area, &da, cfg.chart_title.top_margin, bar);
332    (rect.width > 0.0 && rect.height > 0.0).then_some((bar, rect))
333}
334
335impl Selectable for AxisElement {
336    fn element_id(&self) -> String {
337        format!("axis_{}", side_str(&self.side))
338    }
339
340    fn bounds(&self, cfg: &Config, _measure: &dyn MeasureText) -> Option<RectF> {
341        let axis = axis_of(cfg, &self.side);
342        if !axis.line_visible && matches!(axis.tick, TickVisibility::None) {
343            return None;
344        }
345        let da = cfg.data_area().ok()?;
346
347        Some(axis_visibility_rect(self.side.clone(), &da, axis))
348    }
349
350    fn as_draggable(&self) -> Option<&dyn Draggable> {
351        Some(self)
352    }
353}
354
355impl Selectable for AxisLabelElement {
356    /// Approximate interaction strip for representative label glyphs. It uses
357    /// the renderer's placement formula and a decimal/timestamp sample, but
358    /// does not reproduce the renderer-owned tick set, formatting, pruning,
359    /// or exact rendered-label union.
360    fn element_id(&self) -> String {
361        format!("tick_labels_{}", side_str(&self.side))
362    }
363
364    fn bounds(&self, cfg: &Config, measure: &dyn MeasureText) -> Option<RectF> {
365        let axis = axis_of(cfg, &self.side);
366        let ls = &axis.label_style;
367        if !ls.visible || !ls.label_visible {
368            return None;
369        }
370        let da = cfg.data_area().ok()?;
371        // Representative label extents at the label font/size. Digits share
372        // one height; width approximates a `significant_digits`-long number
373        // (+2 for a sign / decimal point).
374        let m = representative_label_extents(axis, measure);
375
376        let (dax, day) = (da.x as f32, da.y as f32);
377        let (daw, dah) = (da.width as f32, da.height as f32);
378        let tick_position = match self.side {
379            Side::Top => (dax, day),
380            Side::Bottom => (dax, day + dah),
381            Side::Left => (dax, day),
382            Side::Right => (dax + daw, day),
383        };
384        let origin = label_origin(
385            self.side.clone(),
386            tick_position,
387            axis.major_tick_length,
388            (ls.label_offset_x, ls.label_offset_y),
389            m,
390        );
391        let mut strip = label_rect(origin, m);
392        match self.side {
393            Side::Top | Side::Bottom => {
394                strip.x = dax;
395                strip.width = daw;
396            }
397            Side::Left | Side::Right => {
398                strip.y = day;
399                strip.height = dah;
400            }
401        }
402        let (dx, dy) = axis_offset(self.side.clone(), axis.line_offset);
403        Some(strip.translated(dx, dy))
404    }
405
406    fn as_draggable(&self) -> Option<&dyn Draggable> {
407        Some(self)
408    }
409}
410
411impl Selectable for AxisTitleElement {
412    fn element_id(&self) -> String {
413        format!("axis_title_{}", side_str(&self.side))
414    }
415
416    fn bounds(&self, cfg: &Config, measure: &dyn MeasureText) -> Option<RectF> {
417        let axis = axis_of(cfg, &self.side);
418        let to = &axis.title_option;
419        if !to.visible || to.text.segments.is_empty() {
420            return None;
421        }
422        let da = cfg.data_area().ok()?;
423        let m = measure.measure_rich(&to.text);
424        Some(
425            axis_title_placement(
426                self.side.clone(),
427                &cfg.chart_area,
428                &da,
429                TitleBand {
430                    out_margin: axis.out_margin,
431                    chart_title_margin: cfg.chart_title.top_margin,
432                    edge_inset: cfg.colorbar_band(&self.side),
433                },
434                (to.offset_x, to.offset_y),
435                m,
436            )
437            .rect(m),
438        )
439    }
440
441    fn as_draggable(&self) -> Option<&dyn Draggable> {
442        Some(self)
443    }
444}
445
446impl Selectable for ChartTitleElement {
447    fn element_id(&self) -> String {
448        "chart_title".to_string()
449    }
450
451    fn bounds(&self, cfg: &Config, measure: &dyn MeasureText) -> Option<RectF> {
452        let ct = &cfg.chart_title;
453        if !ct.visible || ct.text.segments.is_empty() {
454            return None;
455        }
456        let m = measure.measure_rich(&ct.text);
457        Some(
458            chart_title_placement(
459                &cfg.chart_area,
460                ct.top_margin,
461                (ct.offset_x, ct.offset_y),
462                m,
463            )
464            .rect(m),
465        )
466    }
467
468    fn as_draggable(&self) -> Option<&dyn Draggable> {
469        Some(self)
470    }
471}
472
473impl Selectable for DataAreaElement {
474    fn element_id(&self) -> String {
475        "data_area".to_string()
476    }
477
478    fn bounds(&self, cfg: &Config, _measure: &dyn MeasureText) -> Option<RectF> {
479        cfg.data_area().ok().map(|da| RectF::from_rect(&da.0))
480    }
481
482    fn as_draggable(&self) -> Option<&dyn Draggable> {
483        Some(self)
484    }
485
486    fn as_resizable(&self) -> Option<&dyn Resizable> {
487        Some(self)
488    }
489}
490
491impl Selectable for ColorBarElement {
492    fn element_id(&self) -> String {
493        "colorbar".to_string()
494    }
495
496    /// The strip rect — the same one the renderer paints, offsets included, so
497    /// the highlight box and the eight handles sit on the bar the user sees.
498    /// Ticks and labels are outside it, as they are for an axis.
499    fn bounds(&self, cfg: &Config, _measure: &dyn MeasureText) -> Option<RectF> {
500        visible_colorbar_rect(cfg).map(|(_, rect)| rect)
501    }
502
503    fn as_draggable(&self) -> Option<&dyn Draggable> {
504        Some(self)
505    }
506
507    fn as_resizable(&self) -> Option<&dyn Resizable> {
508        Some(self)
509    }
510}
511
512impl Selectable for ColorBarAxisElement {
513    fn element_id(&self) -> String {
514        "colorbar_axis".to_string()
515    }
516
517    fn bounds(&self, cfg: &Config, _measure: &dyn MeasureText) -> Option<RectF> {
518        let (bar, rect) = visible_colorbar_rect(cfg)?;
519        let axis = &bar.axis;
520        if !axis.line_visible && matches!(axis.tick, TickVisibility::None) {
521            return None;
522        }
523        Some(rect_axis_visibility_rect(bar.side.clone(), &rect, axis))
524    }
525
526    fn as_draggable(&self) -> Option<&dyn Draggable> {
527        Some(self)
528    }
529}
530
531impl Selectable for ColorBarLabelElement {
532    fn element_id(&self) -> String {
533        "colorbar_tick_labels".to_string()
534    }
535
536    fn bounds(&self, cfg: &Config, measure: &dyn MeasureText) -> Option<RectF> {
537        let (bar, rect) = visible_colorbar_rect(cfg)?;
538        let axis = &bar.axis;
539        let ls = &axis.label_style;
540        if !ls.visible || !ls.label_visible {
541            return None;
542        }
543
544        let m = representative_label_extents(axis, measure);
545        let tick_position = point_on_rect_side(0.0, &bar.side, &rect);
546        let origin = label_origin(
547            bar.side.clone(),
548            tick_position,
549            axis.major_tick_length,
550            (ls.label_offset_x, ls.label_offset_y),
551            m,
552        );
553        let mut strip = label_rect(origin, m);
554        match bar.side {
555            Side::Top | Side::Bottom => {
556                strip.x = rect.x;
557                strip.width = rect.width;
558            }
559            Side::Left | Side::Right => {
560                strip.y = rect.y;
561                strip.height = rect.height;
562            }
563        }
564        let (dx, dy) = axis_offset(bar.side.clone(), axis.line_offset);
565        Some(strip.translated(dx, dy))
566    }
567
568    fn as_draggable(&self) -> Option<&dyn Draggable> {
569        Some(self)
570    }
571}
572
573impl Selectable for ColorBarTitleElement {
574    fn element_id(&self) -> String {
575        "colorbar_title".to_string()
576    }
577
578    fn bounds(&self, cfg: &Config, measure: &dyn MeasureText) -> Option<RectF> {
579        let (bar, rect) = visible_colorbar_rect(cfg)?;
580        let title = &bar.axis.title_option;
581        if !title.visible || title.text.segments.is_empty() {
582            return None;
583        }
584        let m = measure.measure_rich(&title.text);
585        Some(
586            colorbar_title_placement(
587                bar.side.clone(),
588                &rect,
589                &bar.axis,
590                (title.offset_x, title.offset_y),
591                m,
592            )
593            .rect(m),
594        )
595    }
596
597    fn as_draggable(&self) -> Option<&dyn Draggable> {
598        Some(self)
599    }
600}
601
602impl Selectable for LegendElement {
603    /// The whole content document is measured as one rich text (`'\n'`
604    /// segments break lines); shared placement expands it by `padding` and
605    /// anchors it at the configured data-area corner.
606    fn element_id(&self) -> String {
607        "legend".to_string()
608    }
609
610    fn bounds(&self, cfg: &Config, measure: &dyn MeasureText) -> Option<RectF> {
611        let lg = &cfg.legend;
612        if !lg.visible || lg.content.segments.is_empty() {
613            return None;
614        }
615        let da = cfg.data_area().ok()?;
616
617        let m = measure.measure_rich(&lg.content);
618        Some(legend_rect(
619            &da,
620            lg.corner,
621            lg.padding,
622            (lg.offset_x, lg.offset_y),
623            m,
624        ))
625    }
626
627    fn as_draggable(&self) -> Option<&dyn Draggable> {
628        Some(self)
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use crate::default::default_config;
636    use crate::layout::{ChartArea, Rect};
637    use crate::text::{RichText, TextExtents, rich_segments_from_text};
638
639    /// `element_id` gives every standard element a distinct, stable name —
640    /// hosts key hover/context UI on these strings.
641    #[test]
642    fn element_ids_are_stable_and_distinct() {
643        let ids = [
644            DataAreaElement.element_id(),
645            AxisElement { side: Side::Bottom }.element_id(),
646            AxisLabelElement { side: Side::Left }.element_id(),
647            AxisTitleElement { side: Side::Left }.element_id(),
648            LegendElement.element_id(),
649            ChartTitleElement.element_id(),
650            ColorBarElement.element_id(),
651            ColorBarAxisElement.element_id(),
652            ColorBarLabelElement.element_id(),
653            ColorBarTitleElement.element_id(),
654        ];
655        assert_eq!(
656            ids,
657            [
658                "data_area",
659                "axis_bottom",
660                "tick_labels_left",
661                "axis_title_left",
662                "legend",
663                "chart_title",
664                "colorbar",
665                "colorbar_axis",
666                "colorbar_tick_labels",
667                "colorbar_title"
668            ]
669        );
670    }
671
672    /// Deterministic stub mirroring the renderer's line splitting: 8 px per
673    /// segment on the widest line, 10 up / 3 down per line, `'\n'` segments
674    /// start a new 13 px line below the first baseline.
675    struct FixedMeasure;
676    impl MeasureText for FixedMeasure {
677        fn measure_rich(&self, rt: &RichText) -> TextExtents {
678            let mut line_lens = vec![0usize];
679            for seg in &rt.segments {
680                if seg.text == '\n' {
681                    line_lens.push(0);
682                } else if seg.text != '\t' {
683                    // '\t' is a column separator (zero-width here; the real
684                    // engine aligns columns — irrelevant for these tests).
685                    *line_lens.last_mut().unwrap() += 1;
686                }
687            }
688            TextExtents {
689                width: line_lens.iter().copied().max().unwrap_or(0) as f32 * 8.0,
690                ascent: 10.0,
691                descent: 3.0 + (line_lens.len() - 1) as f32 * 13.0,
692            }
693        }
694    }
695
696    fn cfg_800x600() -> Config {
697        let mut cfg = default_config();
698        cfg.chart_area = ChartArea(Rect {
699            x: 0,
700            y: 0,
701            width: 800,
702            height: 600,
703        });
704        cfg
705    }
706
707    #[test]
708    fn data_area_bounds_match_layout() {
709        let cfg = cfg_800x600();
710        let da = cfg.data_area().unwrap();
711        let b = DataAreaElement.bounds(&cfg, &FixedMeasure).unwrap();
712        assert_eq!(b, RectF::from_rect(&da.0));
713    }
714
715    #[test]
716    fn chart_title_bounds_centered_in_band() {
717        let mut cfg = cfg_800x600();
718        cfg.chart_title.text.segments = rich_segments_from_text("Title");
719
720        let b = ChartTitleElement.bounds(&cfg, &FixedMeasure).unwrap();
721        // 5 chars × 8 px = 40 wide, centered at x = 400.
722        assert_eq!(b.width, 40.0);
723        assert_eq!(b.height, 13.0);
724        assert!((b.x - (400.0 - 20.0)).abs() < 1e-4);
725        // Vertically inside the 32 px title band.
726        assert!(b.y >= 0.0 && b.y + b.height <= cfg.chart_title.top_margin);
727    }
728
729    #[test]
730    fn hidden_or_empty_title_has_no_bounds() {
731        let mut cfg = cfg_800x600();
732        // Empty text (default) → None.
733        assert!(ChartTitleElement.bounds(&cfg, &FixedMeasure).is_none());
734        // Filled but hidden → None.
735        cfg.chart_title.text.segments = rich_segments_from_text("T");
736        cfg.chart_title.visible = false;
737        assert!(ChartTitleElement.bounds(&cfg, &FixedMeasure).is_none());
738    }
739
740    #[test]
741    fn bottom_axis_bounds_hug_data_area_edge() {
742        let cfg = cfg_800x600();
743        let da = cfg.data_area().unwrap();
744        let b = AxisElement { side: Side::Bottom }
745            .bounds(&cfg, &FixedMeasure)
746            .unwrap();
747        assert_eq!(b.x, da.x as f32);
748        assert_eq!(b.width, da.width as f32);
749        let axis_y = (da.y + da.height) as f32;
750        assert!(b.y <= axis_y && axis_y <= b.y + b.height);
751    }
752
753    #[test]
754    fn left_axis_title_bounds_swap_extents() {
755        let mut cfg = cfg_800x600();
756        cfg.left_y.title_option.text.segments = rich_segments_from_text("Volt"); // 4 chars
757
758        let b = AxisTitleElement { side: Side::Left }
759            .bounds(&cfg, &FixedMeasure)
760            .unwrap();
761        // Rotated 90°: screen width = text height (13), screen height = text width (32).
762        assert_eq!(b.width, 13.0);
763        assert_eq!(b.height, 32.0);
764    }
765
766    #[test]
767    fn selection_box_pads_bounds_with_blue() {
768        let cfg = cfg_800x600();
769        let b = DataAreaElement.bounds(&cfg, &FixedMeasure).unwrap();
770        let sb = DataAreaElement.selection_box(&cfg, &FixedMeasure).unwrap();
771        assert_eq!(sb.rect, b.expanded(SELECTION_PADDING));
772        assert_eq!(sb.color, SELECTION_COLOR);
773        assert_eq!(sb.stroke_width, SELECTION_STROKE_WIDTH);
774    }
775
776    #[test]
777    fn contains_hits_inside_and_misses_outside() {
778        let cfg = cfg_800x600();
779        let b = DataAreaElement.bounds(&cfg, &FixedMeasure).unwrap();
780        let (cx, cy) = (b.x + b.width * 0.5, b.y + b.height * 0.5);
781        assert!(DataAreaElement.contains(&cfg, &FixedMeasure, cx, cy));
782        assert!(!DataAreaElement.contains(&cfg, &FixedMeasure, b.x - 10.0, b.y - 10.0));
783    }
784
785    #[test]
786    fn hitmap_topmost_wins_over_background() {
787        let mut cfg = cfg_800x600();
788        cfg.chart_title.text.segments = rich_segments_from_text("Title");
789        let map = HitMap::standard_chart();
790
791        // A point inside the chart title box must report the title, not the
792        // (earlier-registered) bands behind it.
793        let tb = ChartTitleElement.bounds(&cfg, &FixedMeasure).unwrap();
794        let id = map
795            .hit_test(
796                &cfg,
797                &FixedMeasure,
798                tb.x + tb.width * 0.5,
799                tb.y + tb.height * 0.5,
800            )
801            .unwrap();
802        let sb = map.selection_box(id, &cfg, &FixedMeasure).unwrap();
803        assert_eq!(sb.rect, tb.expanded(SELECTION_PADDING));
804
805        // Center of the data area → data area element.
806        let db = DataAreaElement.bounds(&cfg, &FixedMeasure).unwrap();
807        let id2 = map
808            .hit_test(
809                &cfg,
810                &FixedMeasure,
811                db.x + db.width * 0.5,
812                db.y + db.height * 0.5,
813            )
814            .unwrap();
815        assert_ne!(id, id2);
816
817        // Far outside the chart → no hit.
818        assert!(map.hit_test(&cfg, &FixedMeasure, 5000.0, 5000.0).is_none());
819    }
820
821    #[test]
822    fn bottom_axis_label_strip_hugs_glyph_extent() {
823        let cfg = cfg_800x600();
824        let da = cfg.data_area().unwrap();
825        let b = AxisLabelElement { side: Side::Bottom }
826            .bounds(&cfg, &FixedMeasure)
827            .unwrap();
828        // Starts past the tick + LABEL_GAP; thickness is the measured glyph
829        // height (FixedMeasure: ascent 10 + descent 3), not the whole margin.
830        assert_eq!(
831            b.y,
832            (da.y + da.height) as f32 + cfg.bottom_x.major_tick_length + 4.0
833        );
834        assert_eq!(b.height, 13.0);
835        assert!(b.height < cfg.bottom_x.out_margin);
836        assert_eq!(b.x, da.x as f32);
837        assert_eq!(b.width, da.width as f32);
838    }
839
840    #[test]
841    fn left_axis_label_strip_width_scales_with_digits() {
842        let cfg = cfg_800x600();
843        let da = cfg.data_area().unwrap();
844        let b = AxisLabelElement { side: Side::Left }
845            .bounds(&cfg, &FixedMeasure)
846            .unwrap();
847        // Width = (significant_digits + 2) digits × 8 px under FixedMeasure.
848        let digits = cfg.left_y.label_style.significant_digits.max(1) as f32 + 2.0;
849        assert_eq!(b.width, digits * 8.0);
850        // Right edge sits tick + LABEL_GAP inward of the axis line.
851        assert_eq!(
852            b.x + b.width,
853            da.x as f32 - cfg.left_y.major_tick_length - 4.0
854        );
855    }
856
857    #[test]
858    fn legend_drag_offset_moves_bounds() {
859        use crate::legend::{LegendEntryKind, append_legend_entry, symbol_segments};
860        let mut cfg = cfg_800x600();
861        cfg.legend.visible = true;
862        append_legend_entry(
863            &mut cfg.legend.content,
864            symbol_segments(&LegendEntryKind::Line, crate::color::Color::BLACK),
865            "a",
866        );
867        let before = LegendElement.bounds(&cfg, &FixedMeasure).unwrap();
868
869        // Drag through the trait-object route hosts use.
870        let drag = LegendElement.as_draggable().expect("legend is draggable");
871        assert_eq!(
872            drag.drag_by(&mut cfg, 9.0, -5.0),
873            crate::layout::NudgeResult::Moved
874        );
875        let after = LegendElement.bounds(&cfg, &FixedMeasure).unwrap();
876        assert_eq!(after.x, before.x + 9.0);
877        assert_eq!(after.y, before.y - 5.0);
878    }
879
880    #[test]
881    fn data_area_selection_box_carries_resize_handles() {
882        let cfg = cfg_800x600();
883        let sb = DataAreaElement.selection_box(&cfg, &FixedMeasure).unwrap();
884        assert_eq!(sb.handles.len(), 8);
885        // Non-resizable elements carry none.
886        let sb2 = AxisElement { side: Side::Bottom }
887            .selection_box(&cfg, &FixedMeasure)
888            .unwrap();
889        assert!(sb2.handles.is_empty());
890    }
891
892    #[test]
893    fn hidden_axis_labels_have_no_bounds() {
894        let mut cfg = cfg_800x600();
895        cfg.left_y.label_style.label_visible = false;
896        assert!(
897            AxisLabelElement { side: Side::Left }
898                .bounds(&cfg, &FixedMeasure)
899                .is_none()
900        );
901    }
902
903    #[test]
904    fn legend_bounds_mirror_renderer_box() {
905        use crate::legend::{LegendCorner, LegendEntryKind, append_legend_entry, symbol_segments};
906
907        let mut cfg = cfg_800x600();
908        cfg.legend.visible = true;
909        cfg.legend.corner = LegendCorner::TopRight;
910        // One-line content "— abc": 5 segments × 8 px wide, 13 px tall.
911        append_legend_entry(
912            &mut cfg.legend.content,
913            symbol_segments(&LegendEntryKind::Line, crate::color::Color::BLACK),
914            "abc",
915        );
916
917        let da = cfg.data_area().unwrap();
918        let lg = &cfg.legend;
919        let b = LegendElement.bounds(&cfg, &FixedMeasure).unwrap();
920        let box_w = 5.0 * 8.0 + lg.padding * 2.0;
921        let box_h = 13.0 + lg.padding * 2.0;
922        assert_eq!(b.width, box_w);
923        assert_eq!(b.height, box_h);
924        // One-line content: wider than tall.
925        assert!(b.width > b.height);
926        assert_eq!(b.x, (da.x + da.width) as f32 - box_w - 6.0);
927        assert_eq!(b.y, da.y as f32 + 6.0);
928
929        // Invisible legend → no bounds.
930        let mut hidden = cfg.clone();
931        hidden.legend.visible = false;
932        assert!(LegendElement.bounds(&hidden, &FixedMeasure).is_none());
933    }
934
935    #[test]
936    fn legend_bounds_multiline_content_grows_taller() {
937        use crate::legend::{LegendEntryKind, append_legend_entry, symbol_segments};
938
939        let mut cfg = cfg_800x600();
940        cfg.legend.visible = true;
941        append_legend_entry(
942            &mut cfg.legend.content,
943            symbol_segments(&LegendEntryKind::Line, crate::color::Color::BLACK),
944            "abc",
945        );
946        let one_line = LegendElement.bounds(&cfg, &FixedMeasure).unwrap();
947
948        // Second append inserts an explicit '\n' → one more 13 px line; the
949        // width stays the widest line's.
950        append_legend_entry(
951            &mut cfg.legend.content,
952            symbol_segments(&LegendEntryKind::Line, crate::color::Color::BLACK),
953            "abc",
954        );
955        let two_lines = LegendElement.bounds(&cfg, &FixedMeasure).unwrap();
956        assert_eq!(two_lines.height, one_line.height + 13.0);
957        assert_eq!(two_lines.width, one_line.width);
958    }
959
960    #[test]
961    fn register_into_default_method_round_trips() {
962        let mut cfg = cfg_800x600();
963        cfg.chart_title.text.segments = rich_segments_from_text("T");
964        let mut map = HitMap::new();
965        let id = ChartTitleElement.register_into(&mut map);
966        assert_eq!(map.len(), 1);
967        let tb = ChartTitleElement.bounds(&cfg, &FixedMeasure).unwrap();
968        let hit = map
969            .hit_test(&cfg, &FixedMeasure, tb.x + 1.0, tb.y + 1.0)
970            .unwrap();
971        assert_eq!(hit, id);
972    }
973
974    // ── Colourbar ──────────────────────────────────────────────────────────
975
976    fn cfg_with_colorbar(side: Side) -> Config {
977        let mut cfg = cfg_800x600();
978        let mut bar = crate::default::default_colorbar_options();
979        bar.side = side;
980        cfg.colorbar = Some(bar);
981        cfg
982    }
983
984    /// The bar is selectable like every other piece of chrome, and its bounds
985    /// are the strip the renderer paints — not the band, and not the labels.
986    #[test]
987    fn the_colorbar_is_selectable_and_its_bounds_are_the_strip() {
988        for side in [Side::Top, Side::Bottom, Side::Left, Side::Right] {
989            let cfg = cfg_with_colorbar(side.clone());
990            let da = cfg.data_area().expect("data area");
991            let bar = cfg.colorbar.as_ref().expect("colourbar");
992            let expected = colorbar_rect(&cfg.chart_area, &da, cfg.chart_title.top_margin, bar);
993            assert_eq!(
994                ColorBarElement.bounds(&cfg, &FixedMeasure),
995                Some(expected),
996                "{side:?}"
997            );
998
999            // And it is reachable through the standard hit map.
1000            let map = HitMap::standard_chart();
1001            let id = map
1002                .hit_test(
1003                    &cfg,
1004                    &FixedMeasure,
1005                    expected.x + expected.width * 0.5,
1006                    expected.y + expected.height * 0.5,
1007                )
1008                .expect("the strip is hit-testable");
1009            assert_eq!(map.get(id).expect("element").element_id(), "colorbar");
1010        }
1011    }
1012
1013    /// No colourbar, or a hidden one, is not selectable — the same rule the
1014    /// legend and the titles follow when they are not drawn.
1015    #[test]
1016    fn an_absent_or_hidden_colorbar_is_not_selectable() {
1017        let cfg = cfg_800x600();
1018        assert_eq!(ColorBarElement.bounds(&cfg, &FixedMeasure), None);
1019
1020        let mut hidden = cfg_with_colorbar(Side::Right);
1021        hidden.colorbar.as_mut().expect("colourbar").visible = false;
1022        assert_eq!(ColorBarElement.bounds(&hidden, &FixedMeasure), None);
1023
1024        // A degenerate strip has nothing to select either.
1025        let mut degenerate = cfg_with_colorbar(Side::Right);
1026        degenerate.colorbar.as_mut().expect("colourbar").length_frac = 0.0;
1027        assert_eq!(ColorBarElement.bounds(&degenerate, &FixedMeasure), None);
1028    }
1029
1030    /// Resizable elements get the eight handles automatically. Before this the
1031    /// data area was the only one; the bar is the second.
1032    #[test]
1033    fn the_colorbar_selection_box_carries_resize_handles() {
1034        let cfg = cfg_with_colorbar(Side::Right);
1035        let box_ = ColorBarElement
1036            .selection_box(&cfg, &FixedMeasure)
1037            .expect("selection box");
1038        assert_eq!(box_.handles.len(), 8);
1039        assert_eq!(
1040            box_.rect,
1041            ColorBarElement
1042                .bounds(&cfg, &FixedMeasure)
1043                .expect("bounds")
1044                .expanded(SELECTION_PADDING)
1045        );
1046
1047        // Non-resizable chrome still has none, so the handles mean something.
1048        let title = ChartTitleElement.selection_box(&cfg, &FixedMeasure);
1049        assert!(title.is_none_or(|b| b.handles.is_empty()));
1050    }
1051
1052    #[test]
1053    fn colorbar_axis_labels_and_title_are_independent_hit_targets() {
1054        for side in [Side::Top, Side::Bottom, Side::Left, Side::Right] {
1055            let mut cfg = cfg_with_colorbar(side.clone());
1056            {
1057                let bar = cfg.colorbar.as_mut().expect("colourbar");
1058                bar.axis.title_option.visible = true;
1059                bar.axis.title_option.text =
1060                    RichText::plain("z", crate::color::Color::BLACK, 12.0, "");
1061            }
1062            let map = HitMap::standard_chart();
1063
1064            let axis = ColorBarAxisElement
1065                .bounds(&cfg, &FixedMeasure)
1066                .expect("axis bounds");
1067            let axis_hit = map
1068                .hit_test(
1069                    &cfg,
1070                    &FixedMeasure,
1071                    axis.x + axis.width * 0.5,
1072                    axis.y + axis.height * 0.5,
1073                )
1074                .expect("axis hit");
1075            assert_eq!(map.get(axis_hit).unwrap().element_id(), "colorbar_axis");
1076            let axis_selection = map
1077                .selection_box(axis_hit, &cfg, &FixedMeasure)
1078                .expect("axis selection box");
1079            assert_eq!(axis_selection.rect, axis.expanded(SELECTION_PADDING));
1080            assert!(axis_selection.handles.is_empty());
1081
1082            let labels = ColorBarLabelElement
1083                .bounds(&cfg, &FixedMeasure)
1084                .expect("label bounds");
1085            let label_point = match side {
1086                Side::Top | Side::Bottom => (labels.x + 1.0, labels.y + labels.height * 0.5),
1087                Side::Left | Side::Right => (labels.x + labels.width * 0.5, labels.y + 1.0),
1088            };
1089            let label_hit = map
1090                .hit_test(&cfg, &FixedMeasure, label_point.0, label_point.1)
1091                .expect("label hit");
1092            assert_eq!(
1093                map.get(label_hit).unwrap().element_id(),
1094                "colorbar_tick_labels"
1095            );
1096            let label_selection = map
1097                .selection_box(label_hit, &cfg, &FixedMeasure)
1098                .expect("label selection box");
1099            assert_eq!(label_selection.rect, labels.expanded(SELECTION_PADDING));
1100            assert!(label_selection.handles.is_empty());
1101
1102            let title = ColorBarTitleElement
1103                .bounds(&cfg, &FixedMeasure)
1104                .expect("title bounds");
1105            let title_hit = map
1106                .hit_test(
1107                    &cfg,
1108                    &FixedMeasure,
1109                    title.x + title.width * 0.5,
1110                    title.y + title.height * 0.5,
1111                )
1112                .expect("title hit");
1113            assert_eq!(map.get(title_hit).unwrap().element_id(), "colorbar_title");
1114            let title_selection = map
1115                .selection_box(title_hit, &cfg, &FixedMeasure)
1116                .expect("title selection box");
1117            assert_eq!(title_selection.rect, title.expanded(SELECTION_PADDING));
1118            assert!(title_selection.handles.is_empty());
1119        }
1120    }
1121
1122    #[test]
1123    fn colorbar_detail_bounds_follow_the_painted_strip_offset() {
1124        let mut cfg = cfg_with_colorbar(Side::Right);
1125        {
1126            let bar = cfg.colorbar.as_mut().expect("colourbar");
1127            bar.length_frac = 0.4;
1128            bar.align = crate::config::BarAlign::End;
1129            bar.axis.title_option.visible = true;
1130            bar.axis.title_option.text =
1131                RichText::plain("intensity", crate::color::Color::BLACK, 12.0, "");
1132        }
1133        let before = [
1134            ColorBarAxisElement.bounds(&cfg, &FixedMeasure).unwrap(),
1135            ColorBarLabelElement.bounds(&cfg, &FixedMeasure).unwrap(),
1136            ColorBarTitleElement.bounds(&cfg, &FixedMeasure).unwrap(),
1137        ];
1138        {
1139            let bar = cfg.colorbar.as_mut().expect("colourbar");
1140            bar.offset_x += 17.0;
1141            bar.offset_y -= 11.0;
1142        }
1143        let after = [
1144            ColorBarAxisElement.bounds(&cfg, &FixedMeasure).unwrap(),
1145            ColorBarLabelElement.bounds(&cfg, &FixedMeasure).unwrap(),
1146            ColorBarTitleElement.bounds(&cfg, &FixedMeasure).unwrap(),
1147        ];
1148        for (before, after) in before.into_iter().zip(after) {
1149            assert_eq!(after, before.translated(17.0, -11.0));
1150        }
1151    }
1152
1153    #[test]
1154    fn hidden_colorbar_details_have_no_bounds() {
1155        let mut cfg = cfg_with_colorbar(Side::Right);
1156        cfg.colorbar.as_mut().unwrap().visible = false;
1157        assert!(ColorBarAxisElement.bounds(&cfg, &FixedMeasure).is_none());
1158        assert!(ColorBarLabelElement.bounds(&cfg, &FixedMeasure).is_none());
1159        assert!(ColorBarTitleElement.bounds(&cfg, &FixedMeasure).is_none());
1160    }
1161}