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 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 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#[derive(Debug, Clone, PartialEq)]
241pub struct AxisElement {
242 pub side: Side,
243}
244
245#[derive(Debug, Clone, PartialEq)]
247pub struct AxisTitleElement {
248 pub side: Side,
249}
250
251#[derive(Debug, Clone, PartialEq)]
254pub struct AxisLabelElement {
255 pub side: Side,
256}
257
258#[derive(Debug, Clone, PartialEq)]
260pub struct ChartTitleElement;
261
262#[derive(Debug, Clone, PartialEq)]
264pub struct DataAreaElement;
265
266#[derive(Debug, Clone, PartialEq)]
268pub struct LegendElement;
269
270#[derive(Debug, Clone, PartialEq)]
278pub struct ColorBarElement;
279
280#[derive(Debug, Clone, PartialEq)]
285pub struct ColorBarAxisElement;
286
287#[derive(Debug, Clone, PartialEq)]
289pub struct ColorBarLabelElement;
290
291#[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 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 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 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 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 #[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 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 *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 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 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 assert!(ChartTitleElement.bounds(&cfg, &FixedMeasure).is_none());
734 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"); let b = AxisTitleElement { side: Side::Left }
759 .bounds(&cfg, &FixedMeasure)
760 .unwrap();
761 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 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 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 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 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 let digits = cfg.left_y.label_style.significant_digits.max(1) as f32 + 2.0;
849 assert_eq!(b.width, digits * 8.0);
850 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 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 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 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 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 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 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 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 #[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 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 #[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 let mut degenerate = cfg_with_colorbar(Side::Right);
1026 degenerate.colorbar.as_mut().expect("colourbar").length_frac = 0.0;
1027 assert_eq!(ColorBarElement.bounds(°enerate, &FixedMeasure), None);
1028 }
1029
1030 #[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 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}