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