1use 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
26pub const SELECTION_COLOR: Color = Color {
28 r: 0.13,
29 g: 0.47,
30 b: 0.95,
31 a: 1.0,
32};
33pub const SELECTION_STROKE_WIDTH: f32 = 1.5;
35pub const SELECTION_PADDING: f32 = 2.0;
37
38#[derive(Debug, Clone, PartialEq)]
41pub struct SelectionBox {
42 pub rect: RectF,
44 pub color: Color,
45 pub stroke_width: f32,
46 pub handles: Vec<RectF>,
49}
50
51pub trait Selectable<C: DragConfig = Config> {
57 fn bounds(&self, cfg: &C, measure: &dyn MeasureText) -> Option<RectF>;
62
63 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 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 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 fn as_draggable(&self) -> Option<&dyn Draggable<C>> {
104 None
105 }
106
107 fn as_resizable(&self) -> Option<&dyn Resizable<C>> {
111 None
112 }
113
114 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub struct HitId(usize);
137
138pub struct HitMap<C: DragConfig = Config> {
146 entries: Vec<Box<dyn Selectable<C> + Send + Sync>>,
147}
148
149impl<C: DragConfig> HitMap<C> {
150 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 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 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 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 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#[derive(Debug, Clone, PartialEq)]
249pub struct AxisElement {
250 pub side: Side,
251}
252
253#[derive(Debug, Clone, PartialEq)]
255pub struct AxisTitleElement {
256 pub side: Side,
257}
258
259#[derive(Debug, Clone, PartialEq)]
262pub struct AxisLabelElement {
263 pub side: Side,
264}
265
266#[derive(Debug, Clone, PartialEq)]
268pub struct ChartTitleElement;
269
270#[derive(Debug, Clone, PartialEq)]
272pub struct DataAreaElement;
273
274#[derive(Debug, Clone, PartialEq)]
276pub struct LegendElement;
277
278#[derive(Debug, Clone, PartialEq)]
286pub struct ColorBarElement;
287
288#[derive(Debug, Clone, PartialEq)]
293pub struct ColorBarAxisElement;
294
295#[derive(Debug, Clone, PartialEq)]
297pub struct ColorBarLabelElement;
298
299#[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 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 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 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 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 #[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 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 *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 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 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 assert!(ChartTitleElement.bounds(&cfg, &FixedMeasure).is_none());
743 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"); let b = AxisTitleElement { side: Side::Left }
768 .bounds(&cfg, &FixedMeasure)
769 .unwrap();
770 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 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 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 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 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 let digits = cfg.left_y.label_style.significant_digits.max(1) as f32 + 2.0;
858 assert_eq!(b.width, digits * 8.0);
859 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 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 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 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 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 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 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 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 #[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 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 #[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 let mut degenerate = cfg_with_colorbar(Side::Right);
1035 degenerate.colorbar.as_mut().expect("colourbar").length_frac = 0.0;
1036 assert_eq!(ColorBarElement.bounds(°enerate, &FixedMeasure), None);
1037 }
1038
1039 #[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 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}